Compare commits

..
Author SHA1 Message Date
sead 0b7b51046b fix(ci): update lighthouse appLoc
Build / Build-and-ng-test (pull_request) Successful in 3m43s
Build / Build-and-test-development (pull_request) Successful in 9m43s
Lighthouse Checks / lighthouse (pull_request) Failing after 17m18s
2026-06-16 23:51:10 +02:00
187 changed files with 15509 additions and 29950 deletions
-225
View File
@@ -1,225 +0,0 @@
# Row Level Security — Technical Deep Dive
This document explains **how** Row Level Security (RLS) is implemented in the Data Controller backend. For the user-facing configuration guide, see [docs.datacontroller.io/row-level-security](https://docs.datacontroller.io/row-level-security/).
## Overview
RLS in Data Controller is implemented as **server-side WHERE clause generation**. No data leaves SAS without passing through a dynamically generated filter. The filter is built at runtime per request, based on:
1. The requesting user's group memberships (SAS metadata groups + DC groups)
2. The active rules in the `MPE_ROW_LEVEL_SECURITY` control table
3. The access mode (VIEW / EDIT / download / upload)
Because the filter is expressed as a standard SAS `WHERE` expression, it works against **any** engine — Base SAS datasets, database libraries (via implicit SQL pass-through pushdown), SPDE, CAS libnames, etc.
## Request Flow
```mermaid
flowchart TD
A[Client request\nview / edit / download / upload] --> B{Service mode}
B -->|VIEW / EDIT / DLOAD| C["%mpe_filtermaster(mode, libds)"]
B -->|ULOAD stagedata.sas| C
C --> D["%mpe_getgroups()\nmetadata groups + MPE_GROUPS"]
D --> E{User in &mpeadmins?}
E -->|Yes| F[No RLS filter\n1=1]
E -->|No| G[Lookup active rules in\nMPE_ROW_LEVEL_SECURITY\nfor libref.table + user's groups]
G --> H{Rules found?}
H -->|No| F
H -->|Yes| I["%mp_filtergenerate()\nper group, OR'd together"]
I --> J[WHERE clause written\nto temp fileref]
F --> J
J --> K{Read or Write?}
K -->|Read\nviewdata / getdata / getrawdata| L["where %inc filtref;;\nrows filtered server-side"]
K -->|Write\nstagedata| M[Inverse filter\nwhere not( filtref )]
M --> N{badrecords > 0?}
N -->|Yes| O[Abort submission\nSecurity Problem]
N -->|No| P[Staging proceeds\nto approval workflow]
```
## Key Components
| Component | Location | Role |
|---|---|---|
| `MPE_ROW_LEVEL_SECURITY` | `&mpelib` (DC control library) | The rule table (scope, group, libref, table, logic, subgroup, variable, operator, raw value, active flag) |
| `%mpe_filtermaster()` | `sas/sasjs/macros/mpe_filtermaster.sas` | Master macro that assembles the full WHERE clause for a request |
| `%mpe_getgroups()` | `sas/sasjs/macros/mpe_getgroups.sas` | Resolves group membership (metadata groups via `%dc_getusergroups` + `MPE_GROUPS` DC-internal groups) |
| `%mp_filtergenerate()` | SASjs core | Converts a query table (logic/subgroup/variable/operator/value rows) into WHERE clause text |
| `%mp_filtercheck()` | SASjs core | Validates rule syntax at *edit time* (defence against SAS code injection) |
| `mpe_row_level_security_postedit.sas` | `sas/sasjs/services/hooks/` | Post-edit hook that runs `%mp_filtercheck` whenever the RLS table itself is edited |
## The Modes
`%mpe_filtermaster` accepts a `mode` parameter, and every service that surfaces data calls it with the appropriate mode:
| Mode | Caller | Purpose |
|---|---|---|
| `VIEW` | `services/public/viewdata.sas`, `getchangeinfo.sas` | Read-only table viewer |
| `EDIT` | `services/editors/getdata.sas` | The EDIT grid (adds "current records only" validity filtering) |
| `DLOAD` | `services/public/getrawdata.sas` | Raw file downloads (RLS scope treated as `VIEW`) |
| `ULOAD` | `services/editors/stagedata.sas` | **Upload validation** (RLS scope treated as `EDIT`) |
Scope mapping: `DLOAD` requests match rules with `RLS_SCOPE in ('VIEW','ALL')`; `ULOAD` requests match rules with `RLS_SCOPE in ('EDIT','ALL')`.
## Execution Flow of `%mpe_filtermaster`
The macro writes the final WHERE expression to a temporary **fileref** (`outref`), line by line. (A fileref is used because a generated filter may exceed the 64k macro variable limit — and note that `%include` of a fileref is not allowed directly in a `proc sql` where clause, hence callers typically use it in a DATA step or data step view.)
### 1. User-supplied filter (FILTER_RK)
If the request includes a stored filter (`filter_rk > 0`), its clauses are read from `MPE_FILTERANYTABLE` / `MPE_FILTERSOURCE` and generated first via `%mp_filtergenerate`. RLS clauses are then **AND-ed on top** — a user filter can only ever narrow results, never widen them beyond RLS.
### 2. Validity-date filtering (EDIT / DLOAD only)
`MPE_TABLES` may define `VAR_TXFROM` / `VAR_TXTO` (SCD2-style validity variables) for the target table. Unless the user explicitly filtered on those variables, the macro appends:
```
("<current datetime>"dt < VAR_TXTO)
```
so that only current records are surfaced in the EDIT grid and downloads.
### 3. Group resolution and admin bypass
```sas
%mpe_getgroups(user=%mf_getuser(), outds=work.groups)
```
Groups come from two sources, concatenated:
* SAS metadata groups (Viya / EBI / Base-specific logic in `%dc_getusergroups`)
* The `MPE_GROUPS` DC table (group assignments managed inside Data Controller)
If the user is a member of the `&mpeadmins` group, **RLS is skipped entirely** — admins always see all rows.
### 4. Rule extraction
Non-admin users trigger a lookup of active, current rules:
```sas
create table work.&rlsds as
select rls_group, rls_group_logic, rls_subgroup_logic, rls_subgroup_id,
rls_variable_nm, rls_operator_nm, rls_raw_value
from &mpelib..mpe_row_level_security
where &dc_dttmtfmt. lt tx_to /* only current (non-deleted) rules */
and rls_scope in ("&scopeval",'ALL')
and upcase(rls_group) in (select upcase(groupname) from work.groups)
and rls_libref = "<libref>" and rls_table = "<dsname>"
and rls_active = 1
order by rls_group, rls_subgroup_id;
```
### 5. Clause assembly
If rules exist, they are appended to the fileref as `AND ( ... )`. Each **group** the user belongs to contributes one sub-filter, and the group-level sub-filters are joined with `OR`:
```
AND ( <group 1 filter> OR <group 2 filter> ... )
```
Each group's filter is produced by `%mp_filtergenerate`, which honours:
* `RLS_GROUP_LOGIC` — how subgroups (identified by `RLS_SUBGROUP_ID`) are joined
* `RLS_SUBGROUP_LOGIC` — how individual clauses within a subgroup are joined (AND/OR)
So membership in multiple groups is **permissive** (OR): the user sees the union of rows permitted by each of their groups.
### 6. Empty-filter fallback
If nothing was written to the fileref (no stored filter, no validity vars, no matching RLS rules), the macro writes a literal `1=1` so the fileref can always be consumed as a valid WHERE expression. Absence of rules therefore means **no restriction** — RLS is opt-in per table/group.
## How Callers Apply the Filter
### Read path (viewdata.sas)
```sas
%mpe_filtermaster(VIEW, &libds, dclib=&mpelib, filter_rk=&filter_rk,
outref=filtref, outds=work.query)
data work.viewdata;
set &libds;
where %inc filtref;;
if _n_ > &DC_MAXOBS_WEBVIEW then stop;
run;
```
The fileref is `%include`d directly inside the `where` statement — the filter never passes through client-visible state, so it cannot be tampered with. For database libraries the WHERE expression is pushed down to the database by the SAS engine.
### Write path (stagedata.sas) — inverse filter
Uploads cannot be filtered; instead the filter is **inverted** and any submitted row matching the inverse is rejected:
```sas
%mpe_filtermaster(ULOAD, &libds, dclib=&mpelib, outref=filtref, ...)
/* prepare inverse query */
data _null_;
infile filtref end=eof;
file &tempref;
if _n_=1 then put 'where not(';
input; put _infile_;
if eof then put ')';
run;
data work.badrecords;
set work.jsdata; /* rows submitted by the user */
%inc &tempref/source2;;
run;
%mp_abort(iftrue=(%mf_nobs(work.badrecords)>0)
,msg=%str(Security Problem - N unauthorised records submitted))
```
If even one submitted row falls outside the user's permitted row set, the entire staging request is aborted before any approval/apply step.
## Incompatibility with REPLACE Load Type
RLS with `EDIT` scope is **incompatible** with tables configured with `LOAD_TYPE=REPLACE` in `MPE_TABLES`. A REPLACE load wipes and reloads the entire target table, so row-level filtering of submitted records cannot be enforced meaningfully (the rows a user is *not* allowed to see would also be deleted). Backend validations therefore abort in both directions (see [issue #211](https://git.datacontroller.io/dc/dc/issues/211)):
1. **`mpe_row_level_security_postedit.sas`** — aborts when activating a rule with `RLS_SCOPE in ('EDIT','ALL')` against a table whose current `MPE_TABLES` record has `LOADTYPE='REPLACE'`.
2. **`mpe_tables_postedit.sas`** — aborts when setting `LOADTYPE='REPLACE'` on a table that already has active, current `RLS_SCOPE in ('EDIT','ALL')` rules in `MPE_ROW_LEVEL_SECURITY`.
`VIEW`-scope rules remain compatible with REPLACE loads, since they only affect read paths.
## Edit-Time Validation (Injection Defence)
Because `RLS_RAW_VALUE` is free text that ends up inside a generated WHERE clause, it is a potential SAS code-injection vector. Mitigations:
1. **`mpe_row_level_security_postedit.sas`** — a post-edit hook on the `MPE_ROW_LEVEL_SECURITY` table itself. Every newly staged rule (with `rls_active=1`) is grouped by target `libref.table` and run through `%mp_filtercheck(targetds=..., abort=YES)`, which compiles/tests each clause against the real target table and rejects the whole submission on invalid syntax (see [mp_filtercheck](https://core.sasjs.io/mp__filtercheck_8sas.html)).
2. **Format rules** enforced by validation: character values must be single quoted, `IN`/`NOT IN` values must be bracketed, `BETWEEN` must contain `AND`.
Additionally, editing `MPE_ROW_LEVEL_SECURITY` is itself a Data Controller table edit, so it goes through the normal approval workflow, audit trail (`MPE_AUDIT`), and can be column-restricted via [Column Level Security](https://docs.datacontroller.io/column-level-security/).
## Worked Example
Given these active rules (all for `MYLIB.MYDS`, `RLS_ACTIVE=1`):
| SCOPE | GROUP | GROUP_LOGIC | SUBGRP_LOGIC | SUBGRP_ID | VAR | OP | VALUE |
|---|---|---|---|---|---|---|---|
| ALL | Group 1 | AND | AND | 1 | VAR_2 | IN | ('this','or') |
| ALL | Group 1 | AND | AND | 1 | VAR_3 | < | 42 |
| ALL | Group 2 | AND | AND | 1 | VAR_4 | CONTAINS | 'xyz' |
A non-admin user in **both** groups, opening the VIEW page, gets a fileref containing:
```
AND ( (VAR_2 IN ('this','or') AND VAR_3 < 42) OR (VAR_4 CONTAINS 'xyz') )
```
resulting in:
```sas
data work.viewdata;
set mylib.myds;
where (VAR_2 IN ('this','or') AND VAR_3 < 42) OR (VAR_4 CONTAINS 'xyz');
run;
```
The same user submitting an EDIT upload has the inverse applied to their staged rows; any row not matching the expression above aborts the submission.
## Summary of Security Properties
* **Enforced server-side** in every read service (VIEW/EDIT/DLOAD) and every write service (ULOAD via inverse filter).
* **Admin bypass** is explicit (`&mpeadmins` group membership check).
* **Multi-group semantics are OR** (union of permitted rows); rules within a group/subgroup are AND/OR per configuration.
* **Fail-open by design**: if no rules match a table, it is unrestricted — RLS must be opted into per table.
* **Injection-resistant**: values are validated with `%mp_filtercheck` at the time rules are edited, not at query time.
-21
View File
@@ -1,21 +0,0 @@
# Testing
Backend (SAS) tests are run with the sasjs CLI from the `sas/` directory.
## Commands
- Full deploy + test cycle: `npm run 4gl` (compiles, deploys to the 4gl target, runs makedata) then `sasjs test -t 4gl`.
- Run tests only (no rebuild/redeploy): `sasjs test -t 4gl`.
- Run a subset of tests: `sasjs test -t 4gl SOMESTRING` — the positional argument filters to matching tests (e.g. `sasjs test -t 4gl stagedata.test.3`).
## Results
- Test results: `sas/sasjsresults/testResults.json` / `.csv` / `.xml`
- Individual test logs: `sas/sasjsresults/logs/<testsuite>.log` (e.g. `services_editors_stagedata.test.3.log`)
- Coverage: `sas/sasjsresults/coverage.lcov`
## Notes
- Test source files live in `sas/sasjs/**`; `sas/sasjsbuild/` is generated build output — do not hand-edit it.
- `sasjs test` executes tests against the deployed app, so run `npm run 4gl` first after changing any service/hook/test code.
- Assertions are made with `%mp_assert()`; results are written to `work.test_results`.
-606
View File
@@ -1,606 +0,0 @@
---
name: handsontable
description: >
Use this skill whenever the user asks about Handsontable — a JavaScript data grid component with
spreadsheet-like UX. Triggers include: mentions of "handsontable", "HotTable", "data grid",
"@handsontable/react-wrapper", spreadsheet component in React/Angular/Vue, HyperFormula formulas
inside a grid, cell types (dropdown, checkbox, date, numeric) in a grid context, or questions
about Handsontable theming, plugins, hooks, configuration options, or column/row features. Also
trigger when the user wants to add an editable spreadsheet-like table to a web app, or asks about
copy/paste, sorting, filtering, frozen rows/columns, merged cells, or context menus in a grid.
This skill covers Handsontable used with any framework (React, Angular, Vue 3, vanilla JS) and
HyperFormula as the Formulas plugin engine inside Handsontable. Do NOT use this skill for
headless/standalone HyperFormula usage without Handsontable — that is covered by the separate
"hyperformula" skill.
---
# Handsontable
Handsontable is a JavaScript **data grid component** (not a full spreadsheet application) that
brings spreadsheet-like UX to web apps: cell editing, copy/paste, sorting, filtering, formulas,
keyboard navigation, context menus, merged cells, frozen rows/columns, conditional formatting, data
validation, pagination, and 400+ built-in formulas via HyperFormula.
- **Latest version:** 17.1.0 (May 2026)
- **Frameworks:** Vanilla JS/TS, React (`@handsontable/react-wrapper`), Angular (`@handsontable/angular-wrapper`), Vue 3 (`@handsontable/vue3`)
- **React wrapper requires:** React 18+
- **License:** Dual — free for non-commercial use (`licenseKey: 'non-commercial-and-evaluation'`), paid for commercial. Per-developer annual license, offline validation (no server connection). Tiers: Hobby (free, non-commercial), Trial (free 45 days), Standard (from $999/yr), Priority (from $1,299/yr), Enterprise (custom). See [Pricing](https://handsontable.com/pricing).
Always check `references/docs-map.md` (in this skill folder) for the full organized link directory
when you need to point the user to specific documentation or need to look up more info.
### What this skill does NOT cover
- **Standalone HyperFormula** (headless formula engine without a grid UI) — use the "hyperformula" skill instead.
- **Other data grid libraries** (AG Grid, TanStack Table, etc.).
- **Full spreadsheet applications** — Handsontable is a component you embed, not a standalone app like Google Sheets.
---
## React Quick Start (Recommended)
### Install
```bash
npm install handsontable @handsontable/react-wrapper
```
### Minimal working example
```jsx
import { HotTable } from '@handsontable/react-wrapper';
import { registerAllModules } from 'handsontable/registry';
import 'handsontable/styles/handsontable.min.css';
import 'handsontable/styles/ht-theme-main.min.css';
registerAllModules();
const MyGrid = () => (
<HotTable
themeName="ht-theme-main"
data={[
['', 'Tesla', 'Volvo', 'Toyota', 'Ford'],
['2019', 10, 11, 12, 13],
['2020', 20, 11, 14, 13],
['2021', 30, 15, 12, 13],
]}
colHeaders={true}
rowHeaders={true}
height="auto"
autoWrapRow={true}
autoWrapCol={true}
licenseKey="non-commercial-and-evaluation"
/>
);
export default MyGrid;
```
Key points:
- `registerAllModules()` registers every built-in plugin. To reduce bundle size, import only what
you need — see [Modules guide](https://handsontable.com/docs/react-data-grid/modules/).
- `height` is required (or the grid won't render). Use `"auto"`, a pixel number, or a CSS string.
- All Handsontable configuration options are passed as props on `<HotTable>`.
- Full installation guide: https://handsontable.com/docs/react-data-grid/installation/
---
## Vanilla JS Quick Start
### Install
```bash
npm install handsontable
```
### CDN (jsDelivr)
```html
<script src="https://cdn.jsdelivr.net/npm/handsontable/dist/handsontable.full.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/handsontable/styles/handsontable.min.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/handsontable/styles/ht-theme-main.min.css" />
```
To pin a specific version, add `@17.1` after `handsontable` in the URL (e.g.,
`handsontable@17.1/dist/handsontable.full.min.js`).
### Minimal working example
```js
import Handsontable from 'handsontable';
import 'handsontable/styles/handsontable.min.css';
import 'handsontable/styles/ht-theme-main.min.css';
const container = document.getElementById('grid');
new Handsontable(container, {
data: [
['', 'Tesla', 'Volvo', 'Toyota', 'Ford'],
['2019', 10, 11, 12, 13],
['2020', 20, 11, 14, 13],
['2021', 30, 15, 12, 13],
],
colHeaders: true,
rowHeaders: true,
height: 'auto',
themeName: 'ht-theme-main',
autoWrapRow: true,
autoWrapCol: true,
licenseKey: 'non-commercial-and-evaluation',
});
```
Full JS docs: https://handsontable.com/docs/javascript-data-grid/installation/
---
## Other Frameworks
### Angular (v1719)
```bash
npm install handsontable @handsontable/angular-wrapper
```
The Angular wrapper was modernized in Handsontable v17.1 to align with Angular 1719, simplifying setup and reducing dependencies. Earlier Angular versions are no longer the target — upgrade Angular first if you're below v17.
Docs: https://handsontable.com/docs/angular-data-grid/installation/
### Vue 3
```bash
npm install handsontable @handsontable/vue3
```
Docs: https://handsontable.com/docs/react-data-grid/vue3-installation/
All framework wrappers share the same version number as the core library and expose the same
configuration options. The React examples in this skill translate directly — just use the
framework's component syntax. Note: Vue 2 (`@handsontable/vue`) is deprecated — use Vue 3.
---
## Theming: Light / Dark / Auto
Handsontable v15+ includes a built-in theme system. Three themes ship out of the box: **main**
(default, spreadsheet-like), **horizon** (clean, analytics-focused), **classic** (legacy
replacement).
### CSS file approach (simplest)
Import the base stylesheet plus a theme:
```js
import 'handsontable/styles/handsontable.min.css';
import 'handsontable/styles/ht-theme-main.min.css';
```
Then set the theme on the grid:
```jsx
<HotTable themeName="ht-theme-main" /* ... */ />
```
**Dark mode:**
- System preference (auto): `themeName="ht-theme-main-dark-auto"`
- Forced dark: `themeName="ht-theme-main-dark"`
Replace `main` with `horizon` or `classic` for other themes.
### Theme API approach (runtime switching)
```js
import { mainTheme, registerTheme } from 'handsontable/themes';
const theme = registerTheme(mainTheme)
.setColorScheme('auto') // 'light' | 'dark' | 'auto'
.setDensityType('comfortable'); // 'compact' | 'default' | 'comfortable'
```
Then pass it as a prop:
```jsx
<HotTable theme={theme} /* ... */ />
```
### CDN theme files
```html
<!-- Theme CSS (includes light and dark mode support) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/handsontable/styles/ht-theme-main.min.css" />
```
In v17, dark mode is controlled via `themeName` or the Theme API — there are no separate `-dark` or
`-dark-auto` CSS files. Load the base theme CSS above and set the mode at runtime.
For CSS variable customization, the Theme Builder, Figma design system, and 200+ design tokens, see:
- [Themes guide](https://handsontable.com/docs/react-data-grid/themes/)
- [Theme customization](https://handsontable.com/docs/react-data-grid/theme-customization/)
- [Theme Builder tool](https://handsontable.com/theme-builder)
---
## Common Configuration Patterns
All options below are passed as props on `<HotTable>` (React) or in the config object (vanilla JS).
For the full options reference: https://handsontable.com/docs/react-data-grid/api/options/
### Data binding
```jsx
// Array of arrays
data={[['A1', 'B1'], ['A2', 'B2']]}
// Array of objects
data={[{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]}
columns={[{ data: 'id' }, { data: 'name' }]}
```
Docs: https://handsontable.com/docs/react-data-grid/binding-to-data/
### Column & row headers
```jsx
colHeaders={['ID', 'Name', 'Price']} // custom labels, or {true} for A, B, C...
rowHeaders={true} // 1, 2, 3... or pass an array
```
### Column types (cell types)
Set via the `columns` array or `cells` function. Built-in types:
- `text` (default), `numeric`, `date`, `time`, `checkbox`, `select`, `dropdown`,
`autocomplete`, `password`, `handsontable` (nested grid), `multiselect` (v17+)
```jsx
columns={[
{ data: 'name', type: 'text' },
{ data: 'price', type: 'numeric', numericFormat: { pattern: '$0,0.00' } },
{ data: 'active', type: 'checkbox' },
{ data: 'category', type: 'dropdown', source: ['A', 'B', 'C'] },
]}
```
Full cell types reference: https://handsontable.com/docs/react-data-grid/cell-type/
### Sorting & filtering
```jsx
columnSorting={true} // single-column sort
// or
multiColumnSorting={true} // multi-column sort
filters={true} // enable column filters
dropdownMenu={true} // column header menu with filter UI
```
### Frozen rows/columns
```jsx
fixedRowsTop={1}
fixedRowsBottom={1}
fixedColumnsStart={2}
```
### Other common options
```jsx
readOnly={true} // entire grid read-only (or per-cell/column)
contextMenu={true} // right-click menu
mergeCells={[{ row: 0, col: 0, rowspan: 2, colspan: 2 }]}
manualColumnResize={true}
manualRowResize={true}
stretchH="all" // stretch columns to fill width: 'none' | 'last' | 'all'
```
### Per-column config with HotColumn (React)
```jsx
import { HotTable, HotColumn } from '@handsontable/react-wrapper';
<HotTable data={data} licenseKey="non-commercial-and-evaluation">
<HotColumn title="Name" data="name" />
<HotColumn title="Active" data="active" type="checkbox" />
</HotTable>
```
Docs: https://handsontable.com/docs/react-data-grid/hot-column/
---
## HyperFormula Integration (Formulas Plugin)
HyperFormula powers Handsontable's Formulas plugin, providing 400+ spreadsheet functions (SUM,
AVERAGE, IF, VLOOKUP, etc.). This section covers using HyperFormula **inside** Handsontable only.
### Install
```bash
npm install hyperformula
```
> **Note:** Starting in Handsontable v18, HyperFormula will no longer be bundled. Install it
> separately and pass it to the Formulas plugin.
### CDN
```html
<script src="https://cdn.jsdelivr.net/npm/hyperformula/dist/hyperformula.full.min.js"></script>
```
### Simple setup (auto-created instance)
Pass the `HyperFormula` class directly — Handsontable creates an instance automatically:
```jsx
import { HyperFormula } from 'hyperformula';
<HotTable
data={[
['10', '20', '=SUM(A1:B1)'],
['30', '40', '=SUM(A2:B2)'],
]}
formulas={{ engine: HyperFormula }}
colHeaders={true}
licenseKey="non-commercial-and-evaluation"
/>
```
### External instance (for multi-sheet or shared engine)
Create a HyperFormula instance with the `'internal-use-in-handsontable'` license key:
```jsx
import { HyperFormula } from 'hyperformula';
const hfInstance = HyperFormula.buildEmpty({
licenseKey: 'internal-use-in-handsontable',
});
// Sheet 1
<HotTable
data={data1}
formulas={{ engine: hfInstance, sheetName: 'Sheet1' }}
licenseKey="non-commercial-and-evaluation"
/>
// Sheet 2 — shares the same engine, enabling cross-sheet references
<HotTable
data={data2}
formulas={{ engine: hfInstance, sheetName: 'Sheet2' }}
licenseKey="non-commercial-and-evaluation"
/>
```
Cross-sheet formula example: `=SUM(Sheet1!A:A)`
### Named expressions
```jsx
formulas={{
engine: HyperFormula,
namedExpressions: [
{ name: 'TAX_RATE', expression: 0.21 },
],
}}
```
Use in cells: `=A1 * TAX_RATE`
### Listening for formula changes
```jsx
<HotTable
formulas={{ engine: HyperFormula }}
afterFormulasValuesUpdate={(changes) => {
changes.forEach((c) => console.log(c.address, c.newValue));
}}
/>
```
### Known limitations
- Formulas don't work with nested object data.
- Moving rows/columns with formulas requires the ManualRowMove / ManualColumnMove plugins (not IndexMapper).
- `getSourceData()` operates on physical indexes; formulas use visual indexes.
Full guide: https://handsontable.com/docs/react-data-grid/formula-calculation/
HyperFormula functions list: https://hyperformula.handsontable.com/docs/guide/built-in-functions.html
HyperFormula version compatibility table: https://handsontable.com/docs/react-data-grid/formula-calculation/#hyperformula-version-support
---
## Server-side data with DataProvider (v17.1+)
The **DataProvider plugin** (new in v17.1) wires the grid up to a remote data source so rows are fetched, sorted, and mutated server-side instead of held in memory. Use it for datasets too large to load up front, or when the source of truth lives in a backend.
The `dataProvider` option is an object with five required keys: a row-id resolver, a paginated `fetchRows` callback, and three mutation callbacks. For a read-only grid, stub the mutation callbacks with `async () => {}` — they must still be present.
```jsx
<HotTable
colHeaders={['ID', 'Name', 'Email']}
pagination={{ pageSize: 25 }}
columnSorting={true}
emptyDataState={true}
height={360}
licenseKey="non-commercial-and-evaluation"
dataProvider={{
// Required: how to extract a stable id from each row
rowId: 'id',
// Required: paginated fetch. `sort` is null when no column is sorted.
// Second arg carries an AbortSignal — pass it to fetch() so the plugin
// can cancel superseded requests when the user pages/sorts quickly.
async fetchRows({ page, pageSize, sort }, { signal }) {
const params = new URLSearchParams({ page, pageSize });
if (sort) {
// sort = { prop: string, order: 'asc' | 'desc' }
params.set('sortBy', sort.prop);
params.set('order', sort.order);
}
const res = await fetch(`/api/users?${params}`, { signal });
const { rows, totalRows } = await res.json();
return { rows, totalRows };
},
// Required. Stub with async () => {} if your grid is read-only.
// The plugin auto-refetches the current page after each callback resolves.
// See the plugin API ref for the exact payload shapes.
onRowsCreate: async () => {},
onRowsUpdate: async () => {},
onRowsRemove: async () => {},
}}
/>
```
### Loading and error UI
The plugin fires three hooks you can wire up as `<HotTable>` props for loading indicators and error surfaces:
- `beforeDataProviderFetch(params)` — fires before each fetch. `params.skipLoading` is set when the plugin wants to suppress your loading indicator (e.g., during a quick refetch).
- `afterDataProviderFetch(result)` — fires after a successful fetch.
- `afterDataProviderFetchError(error)` — fires when `fetchRows` throws or returns a rejected promise.
### Companion options
The plugin is built to pair with `pagination` (paginates server-side), `columnSorting` (single-column server-side sort), and `emptyDataState` (loading + empty state UI). Enable all three when you use DataProvider.
### Authoritative references
- Guide: https://handsontable.com/docs/react-data-grid/server-side-data-fetching/
- Recipe (REST API): https://handsontable.com/docs/react-data-grid/recipes/data-management/load-data-rest-api/
- Plugin API: https://handsontable.com/docs/react-data-grid/api/data-provider/
## Notifications (v17.1+)
The **Notification plugin** (new in v17.1) shows non-blocking toast notifications anchored to the grid — useful for confirming saves, surfacing validation errors, or signaling background sync state. Enable with `notifications: true` and trigger via `hot.getPlugin('notifications').showMessage(...)`. See the plugin guide for placement, severity levels, and auto-dismiss timing: https://handsontable.com/docs/react-data-grid/notification/
---
## Events & Hooks
Handsontable hooks are passed as props on `<HotTable>`:
```jsx
<HotTable
afterChange={(changes, source) => {
if (source !== 'loadData') {
console.log('Cell changed:', changes);
}
}}
beforeChange={(changes, source) => {
// Return false to cancel the edit
}}
afterSelection={(row, col, row2, col2) => {
console.log('Selected:', row, col, 'to', row2, col2);
}}
/>
```
There are 100+ hooks available. Full reference: https://handsontable.com/docs/react-data-grid/api/hooks/
Guide: https://handsontable.com/docs/react-data-grid/events-and-hooks/
---
## Accessing the Instance (Ref)
Use a ref to call Handsontable's core methods:
```jsx
import { useRef } from 'react';
const MyGrid = () => {
const hotRef = useRef(null);
const handleClick = () => {
const hot = hotRef.current?.hotInstance;
if (hot) {
console.log(hot.getData()); // get all data
hot.selectCell(0, 0); // select a cell
}
};
return (
<>
<HotTable ref={hotRef} /* ...options */ />
<button onClick={handleClick}>Get Data</button>
</>
);
};
```
Core API reference: https://handsontable.com/docs/react-data-grid/api/core/
---
## Performance & Large Datasets
Handsontable virtualizes rendering automatically — only visible rows and columns are in the DOM, so
grids with 10k100k+ rows perform well out of the box. For bulk programmatic updates, wrap mutations
in `batch()` to defer re-rendering until all changes are applied:
```jsx
hotRef.current.hotInstance.batch(() => {
// set many cells, add rows, etc.
});
```
For further tuning (disabling auto-size, reducing plugin overhead), see:
- [Performance guide](https://handsontable.com/docs/react-data-grid/performance/)
- [Batch operations](https://handsontable.com/docs/react-data-grid/batch-operations/)
- [Bundle size optimization](https://handsontable.com/docs/react-data-grid/bundle-size/)
---
## Recipes (v17+)
Handsontable v17 introduced a Recipes section in the docs — ready-made patterns for common use cases
(data validation workflows, dynamic column generation, etc.). Check the recipes index before building
something from scratch: https://handsontable.com/docs/react-data-grid/recipes/
---
## Common Pitfalls
- **Forgetting `height`**: The grid won't render without a `height` prop. Use `"auto"`, a pixel value, or a CSS string.
- **Not filtering `loadData` in `afterChange`**: The `afterChange` hook fires on initial data load with `source === 'loadData'`. Always check the source to avoid infinite loops when syncing changes back to state.
- **Using the old wrapper packages**: v17 removed `@handsontable/react` and `@handsontable/angular`. Use `@handsontable/react-wrapper` and `@handsontable/angular-wrapper`.
- **Using legacy CSS imports**: `handsontable.full.min.css` was removed in v17. Use `handsontable/styles/handsontable.min.css` plus a theme file.
- **Formulas with nested object data**: HyperFormula formulas don't work when `data` is an array of nested objects — use flat objects or arrays of arrays.
- **ExportFile `columnHeaders` renamed**: In v17.1 the ExportFile plugin's `columnHeaders` option was renamed to `colHeaders` to match the table-level option. Update any `exportAsString` / `exportAsBlob` / `downloadFile` calls that pass `columnHeaders: ...`.
---
## Version Awareness
Handsontable docs are versioned. The latest docs live at `/docs/react-data-grid/` (which redirects
to the current version). To link to a specific version, use `/docs/17.0/react-data-grid/`.
When helping a user, check which version they are on — breaking changes between major versions are
common. Point them to the relevant migration guide if they're upgrading.
For the full organized directory of documentation links, read `references/docs-map.md` in this
skill's folder.
### v17.1 changes (latest, May 2026)
- **New plugins:** DataProvider (server-side row loading via `dataProvider` option), Notification (toast notifications).
- **NestedHeaders rowspan:** column headers can now span multiple header rows.
- **ExportFile:** XLSX export added; the `columnHeaders` option was renamed to `colHeaders` (see Common Pitfalls).
- **Angular wrapper:** modernized for Angular 1719; simpler setup, fewer deps.
- **Touch:** long-press now opens the context menu on touch devices.
- **TypeScript:** `dateFormat` option now accepts `Intl.DateTimeFormatOptions`.
- No removals or deprecations in v17.1.
### v17.0 Breaking Changes
- Removed legacy wrapper packages (`@handsontable/react`, `@handsontable/angular`). Use
`@handsontable/react-wrapper` and `@handsontable/angular-wrapper`.
- Removed legacy CSS (`handsontable.full.min.css`). Use `handsontable/styles/` imports.
- Removed core-js from dependencies.
- Removed the PersistentState plugin.
- Deprecated bundled HyperFormula (will require separate install in v18).
- Deprecated numbro.js, Pikaday, moment.js, DOMPurify — use native alternatives.
Migration guide: https://handsontable.com/docs/react-data-grid/migration-from-16.2-to-17.0/
@@ -1,231 +0,0 @@
# Handsontable Documentation Map
> Last verified: May 2026 · Aligned with Handsontable 17.1.0
Organized link directory for all Handsontable and HyperFormula (in-grid) documentation. Use these
to point users to the right page. Links default to the React docs; replace `react-data-grid` with
`javascript-data-grid`, `angular-data-grid`, or prefix with `vue3-` for other frameworks.
## Getting Started
- Installation: https://handsontable.com/docs/react-data-grid/installation/
- Demo: https://handsontable.com/docs/react-data-grid/demo/
- Binding to data: https://handsontable.com/docs/react-data-grid/binding-to-data/
- Server-side data fetching (DataProvider, v17.1+): https://handsontable.com/docs/react-data-grid/server-side-data-fetching/
- Saving data: https://handsontable.com/docs/react-data-grid/saving-data/
- Configuration options guide: https://handsontable.com/docs/react-data-grid/configuration-options/
- Grid size: https://handsontable.com/docs/react-data-grid/grid-size/
- Instance methods: https://handsontable.com/docs/react-data-grid/instance-methods/
- Events and hooks: https://handsontable.com/docs/react-data-grid/events-and-hooks/
- License key: https://handsontable.com/docs/react-data-grid/license-key/
- Redux integration: https://handsontable.com/docs/react-data-grid/redux/
## Styling & Themes
- Themes guide: https://handsontable.com/docs/react-data-grid/themes/
- Design system (Figma): https://handsontable.com/docs/react-data-grid/handsontable-design-system/
- Theme customization (CSS vars, tokens): https://handsontable.com/docs/react-data-grid/theme-customization/
- Legacy style (pre-v15): https://handsontable.com/docs/react-data-grid/legacy-style/
- Theme Builder tool: https://handsontable.com/theme-builder
- Figma Theme Generator: https://github.com/handsontable/handsontable-figma
## Columns
- HotColumn component (React): https://handsontable.com/docs/react-data-grid/hot-column/
- Column headers: https://handsontable.com/docs/react-data-grid/column-header/
- Column groups (nested headers): https://handsontable.com/docs/react-data-grid/column-groups/
- Column hiding: https://handsontable.com/docs/react-data-grid/column-hiding/
- Column moving: https://handsontable.com/docs/react-data-grid/column-moving/
- Column freezing: https://handsontable.com/docs/react-data-grid/column-freezing/
- Column widths: https://handsontable.com/docs/react-data-grid/column-width/
- Column summary: https://handsontable.com/docs/react-data-grid/column-summary/
- Column virtualization: https://handsontable.com/docs/react-data-grid/column-virtualization/
- Column menu (dropdown): https://handsontable.com/docs/react-data-grid/column-menu/
- Column filter: https://handsontable.com/docs/react-data-grid/column-filter/
## Rows
- Row headers: https://handsontable.com/docs/react-data-grid/row-header/
- Row parent-child (nested rows): https://handsontable.com/docs/react-data-grid/row-parent-child/
- Row hiding: https://handsontable.com/docs/react-data-grid/row-hiding/
- Row moving: https://handsontable.com/docs/react-data-grid/row-moving/
- Row freezing: https://handsontable.com/docs/react-data-grid/row-freezing/
- Row heights: https://handsontable.com/docs/react-data-grid/row-height/
- Row virtualization: https://handsontable.com/docs/react-data-grid/row-virtualization/
- Rows sorting: https://handsontable.com/docs/react-data-grid/rows-sorting/
- Rows pagination: https://handsontable.com/docs/react-data-grid/rows-pagination/
- Row trimming: https://handsontable.com/docs/react-data-grid/row-trimming/
- Row pre-populating: https://handsontable.com/docs/react-data-grid/row-prepopulating/
## Cell Features
- Clipboard (copy/paste): https://handsontable.com/docs/react-data-grid/basic-clipboard/
- Selection: https://handsontable.com/docs/react-data-grid/selection/
- Merge cells: https://handsontable.com/docs/react-data-grid/merge-cells/
- Conditional formatting: https://handsontable.com/docs/react-data-grid/conditional-formatting/
- Text alignment: https://handsontable.com/docs/react-data-grid/text-alignment/
- Disabled cells: https://handsontable.com/docs/react-data-grid/disabled-cells/
- Comments: https://handsontable.com/docs/react-data-grid/comments/
- Autofill values: https://handsontable.com/docs/react-data-grid/autofill-values/
- Formatting cells: https://handsontable.com/docs/react-data-grid/formatting-cells/
## Cell Functions
- Cell functions overview: https://handsontable.com/docs/react-data-grid/cell-function/
- Cell renderer: https://handsontable.com/docs/react-data-grid/cell-renderer/
- Cell editor: https://handsontable.com/docs/react-data-grid/cell-editor/
- Cell validator: https://handsontable.com/docs/react-data-grid/cell-validator/
- Custom cells (v17+): https://handsontable.com/docs/react-data-grid/custom-cells/
## Cell Types
- Cell type overview: https://handsontable.com/docs/react-data-grid/cell-type/
- Numeric: https://handsontable.com/docs/react-data-grid/numeric-cell-type/
- Date: https://handsontable.com/docs/react-data-grid/date-cell-type/
- Time: https://handsontable.com/docs/react-data-grid/time-cell-type/
- Checkbox: https://handsontable.com/docs/react-data-grid/checkbox-cell-type/
- Select: https://handsontable.com/docs/react-data-grid/select-cell-type/
- Dropdown: https://handsontable.com/docs/react-data-grid/dropdown-cell-type/
- Autocomplete: https://handsontable.com/docs/react-data-grid/autocomplete-cell-type/
- MultiSelect (v17+): https://handsontable.com/docs/react-data-grid/multiselect-cell-type/
- Password: https://handsontable.com/docs/react-data-grid/password-cell-type/
- Handsontable (nested grid): https://handsontable.com/docs/react-data-grid/handsontable-cell-type/
## Formulas (HyperFormula in Handsontable)
- Formula calculation guide: https://handsontable.com/docs/react-data-grid/formula-calculation/
- Formulas plugin API: https://handsontable.com/docs/react-data-grid/api/formulas/
- HyperFormula built-in functions list: https://hyperformula.handsontable.com/docs/guide/built-in-functions.html
- HyperFormula configuration options: https://hyperformula.handsontable.com/docs/guide/configuration-options.html
- HyperFormula custom functions: https://hyperformula.handsontable.com/docs/guide/custom-functions.html
- HyperFormula named expressions: https://hyperformula.handsontable.com/docs/guide/cell-references.html#relative-references
- HyperFormula license key: https://hyperformula.handsontable.com/docs/guide/license-key.html
## Navigation
- Keyboard shortcuts: https://handsontable.com/docs/react-data-grid/keyboard-shortcuts/
- Custom shortcuts: https://handsontable.com/docs/react-data-grid/custom-shortcuts/
- Focus scopes: https://handsontable.com/docs/react-data-grid/focus-scopes/
- Searching values: https://handsontable.com/docs/react-data-grid/searching-values/
## Accessibility
- Accessibility guide: https://handsontable.com/docs/react-data-grid/accessibility/
## Menus & Accessories
- Context menu: https://handsontable.com/docs/react-data-grid/context-menu/
- Undo and redo: https://handsontable.com/docs/react-data-grid/undo-redo/
- Icon pack: https://handsontable.com/docs/react-data-grid/icon-pack/
- Export to CSV: https://handsontable.com/docs/react-data-grid/export-to-csv/
- Export to Excel/XLSX (v17.1+): https://handsontable.com/docs/react-data-grid/export-to-excel/
- Notification (v17.1+): https://handsontable.com/docs/react-data-grid/notification/
- Empty data state: https://handsontable.com/docs/react-data-grid/empty-data-state/
## Dialog & Loading
- Dialog: https://handsontable.com/docs/react-data-grid/dialog/
- Loading indicator: https://handsontable.com/docs/react-data-grid/loading/
## Internationalization
- Language: https://handsontable.com/docs/react-data-grid/language/
- Locale: https://handsontable.com/docs/react-data-grid/locale/
- Layout direction (RTL/LTR): https://handsontable.com/docs/react-data-grid/layout-direction/
- IME support: https://handsontable.com/docs/react-data-grid/ime-support/
## Tools & Building
- Modules (tree-shaking): https://handsontable.com/docs/react-data-grid/modules/
- Custom plugins: https://handsontable.com/docs/react-data-grid/custom-plugins/
- Custom builds: https://handsontable.com/docs/react-data-grid/custom-builds/
- Testing: https://handsontable.com/docs/react-data-grid/testing/
## Optimization
- Batch operations: https://handsontable.com/docs/react-data-grid/batch-operations/
- Performance: https://handsontable.com/docs/react-data-grid/performance/
- Bundle size: https://handsontable.com/docs/react-data-grid/bundle-size/
## Security
- Security guide: https://handsontable.com/docs/react-data-grid/security/
## API Reference
- API overview: https://handsontable.com/docs/react-data-grid/api/
- Core (instance methods): https://handsontable.com/docs/react-data-grid/api/core/
- Hooks (all events): https://handsontable.com/docs/react-data-grid/api/hooks/
- Configuration options (all props): https://handsontable.com/docs/react-data-grid/api/options/
- Plugins index: https://handsontable.com/docs/react-data-grid/api/plugins/
### Individual Plugin APIs
- AutoColumnSize: https://handsontable.com/docs/react-data-grid/api/auto-column-size/
- AutoRowSize: https://handsontable.com/docs/react-data-grid/api/auto-row-size/
- Autofill: https://handsontable.com/docs/react-data-grid/api/autofill/
- BindRowsWithHeaders: https://handsontable.com/docs/react-data-grid/api/bind-rows-with-headers/
- CollapsibleColumns: https://handsontable.com/docs/react-data-grid/api/collapsible-columns/
- ColumnSorting: https://handsontable.com/docs/react-data-grid/api/column-sorting/
- ColumnSummary: https://handsontable.com/docs/react-data-grid/api/column-summary/
- Comments: https://handsontable.com/docs/react-data-grid/api/comments/
- ContextMenu: https://handsontable.com/docs/react-data-grid/api/context-menu/
- CopyPaste: https://handsontable.com/docs/react-data-grid/api/copy-paste/
- CustomBorders: https://handsontable.com/docs/react-data-grid/api/custom-borders/
- DataProvider (v17.1+): https://handsontable.com/docs/react-data-grid/api/data-provider/
- Dialog: https://handsontable.com/docs/react-data-grid/api/dialog/
- DragToScroll: https://handsontable.com/docs/react-data-grid/api/drag-to-scroll/
- DropdownMenu: https://handsontable.com/docs/react-data-grid/api/dropdown-menu/
- EmptyDataState: https://handsontable.com/docs/react-data-grid/api/empty-data-state/
- ExportFile: https://handsontable.com/docs/react-data-grid/api/export-file/
- Filters: https://handsontable.com/docs/react-data-grid/api/filters/
- Formulas: https://handsontable.com/docs/react-data-grid/api/formulas/
- HiddenColumns: https://handsontable.com/docs/react-data-grid/api/hidden-columns/
- HiddenRows: https://handsontable.com/docs/react-data-grid/api/hidden-rows/
- Loading: https://handsontable.com/docs/react-data-grid/api/loading/
- ManualColumnFreeze: https://handsontable.com/docs/react-data-grid/api/manual-column-freeze/
- ManualColumnMove: https://handsontable.com/docs/react-data-grid/api/manual-column-move/
- ManualColumnResize: https://handsontable.com/docs/react-data-grid/api/manual-column-resize/
- ManualRowMove: https://handsontable.com/docs/react-data-grid/api/manual-row-move/
- ManualRowResize: https://handsontable.com/docs/react-data-grid/api/manual-row-resize/
- MergeCells: https://handsontable.com/docs/react-data-grid/api/merge-cells/
- MultiColumnSorting: https://handsontable.com/docs/react-data-grid/api/multi-column-sorting/
- NestedHeaders: https://handsontable.com/docs/react-data-grid/api/nested-headers/
- NestedRows: https://handsontable.com/docs/react-data-grid/api/nested-rows/
- Notification (v17.1+): https://handsontable.com/docs/react-data-grid/api/notification/
- Pagination: https://handsontable.com/docs/react-data-grid/api/pagination/
- Search: https://handsontable.com/docs/react-data-grid/api/search/
- StretchColumns: https://handsontable.com/docs/react-data-grid/api/stretch-columns/
- TrimRows: https://handsontable.com/docs/react-data-grid/api/trim-rows/
- UndoRedo: https://handsontable.com/docs/react-data-grid/api/undo-redo/
## Recipes (new in v17)
- Recipes index: https://handsontable.com/docs/react-data-grid/recipes/
- Load data from a REST API (DataProvider, v17.1+): https://handsontable.com/docs/react-data-grid/recipes/data-management/load-data-rest-api/
## Upgrade & Migration
- Changelog: https://handsontable.com/docs/react-data-grid/changelog/
- Versioning policy: https://handsontable.com/docs/react-data-grid/versioning-policy/
- Deprecation policy: https://handsontable.com/docs/react-data-grid/deprecation-policy/
- Long Term Support (LTS): https://handsontable.com/docs/react-data-grid/long-term-support/
- 16.2 → 17.0 migration: https://handsontable.com/docs/react-data-grid/migration-from-16.2-to-17.0/
- 16.0 → 16.1 migration: https://handsontable.com/docs/react-data-grid/migration-from-16.0-to-16.1/
- 15.3 → 16.0 migration: https://handsontable.com/docs/react-data-grid/migration-from-15.3-to-16.0/
- 14.6 → 15.0 migration: https://handsontable.com/docs/react-data-grid/migration-from-14.6-to-15.0/
## Other Framework Installation
- JavaScript: https://handsontable.com/docs/javascript-data-grid/installation/
- Angular: https://handsontable.com/docs/angular-data-grid/installation/
- Vue 3: https://handsontable.com/docs/react-data-grid/vue3-installation/
## SSR Examples (CodeSandbox)
- Next.js: https://codesandbox.io/p/sandbox/kwnjph?file=https://handsontable.com/codesandbox-vm?example-dir=next.js&handsontable-version=17.1preview=true
- Astro: https://codesandbox.io/p/sandbox/gnqcwn?file=https://handsontable.com/codesandbox-vm?example-dir=astro&handsontable-version=17.1preview=true
- Remix: https://codesandbox.io/p/sandbox/njcjlq?file=https://handsontable.com/codesandbox-vm?example-dir=remix&handsontable-version=17.1preview=true
- Nuxt: https://codesandbox.io/p/sandbox/r7qsjc?file=https://handsontable.com/codesandbox-vm?example-dir=nuxt&handsontable-version=17.1preview=true
## CDN Links (jsDelivr) — latest
- JS (full bundle): https://cdn.jsdelivr.net/npm/handsontable/dist/handsontable.full.min.js
- Base CSS: https://cdn.jsdelivr.net/npm/handsontable/styles/handsontable.min.css
- Theme Main: https://cdn.jsdelivr.net/npm/handsontable/styles/ht-theme-main.min.css
- Theme Horizon: https://cdn.jsdelivr.net/npm/handsontable/styles/ht-theme-horizon.min.css
- Theme Classic: https://cdn.jsdelivr.net/npm/handsontable/styles/ht-theme-classic.min.css
- React wrapper: https://cdn.jsdelivr.net/npm/@handsontable/react-wrapper/dist/react-handsontable.min.js
- HyperFormula: https://cdn.jsdelivr.net/npm/hyperformula/dist/hyperformula.full.min.js
Pin versions by adding `@17.1` after the package name (e.g., `handsontable@17.1`).
## Package Registries
- npm: https://www.npmjs.com/package/handsontable
- NuGet: https://www.nuget.org/packages/handsontable
## Community & Support
- Developer Forum: https://forum.handsontable.com/
- GitHub Issues: https://github.com/handsontable/handsontable/issues
- Stack Overflow: https://stackoverflow.com/tags/handsontable
- Commercial support: support@handsontable.com
- Contact form: https://handsontable.com/contact
- Blog / Release Notes: https://handsontable.com/blog/categories/release-notes
- Download page: https://handsontable.com/download
-85
View File
@@ -1,85 +0,0 @@
# @handsontable/hyperformula-skill
An agent skill for [**HyperFormula**](https://hyperformula.handsontable.com) — the headless, open-source TypeScript spreadsheet calculation engine. It gives any AI coding agent deep, task-oriented knowledge of HyperFormula so the agent can help you integrate, configure, and debug the engine faster.
This package is the npm distribution of the `hyperformula` skill maintained in [`handsontable/handsontable-skills`](https://github.com/handsontable/handsontable-skills). The package version tracks the HyperFormula product version the skill targets (e.g. `3.3.0` targets HyperFormula 3.3.0).
## What's in the package
A self-contained skill — plain markdown, no runtime dependencies:
```
SKILL.md ← entry point: concepts, routing, and a docs map (YAML frontmatter + body)
references/ ← task-oriented deep-dives the agent reads on demand
```
`SKILL.md` follows the [Agent Skills](https://claude.com/blog/skills) layout (a YAML frontmatter block with `name`/`description`, followed by markdown instructions). Any agent that can load a skill — or simply read a folder of markdown instructions — can use it.
## Install
```bash
npm install @handsontable/hyperformula-skill
```
The installed package lives at `node_modules/@handsontable/hyperformula-skill`. Resolve that path programmatically with:
```bash
node -p "require.resolve('@handsontable/hyperformula-skill/SKILL.md')"
```
Then make the skill available to your agent using whichever of the following matches it:
### Claude Code
Copy the package into a skills directory. User scope (available in every project):
```bash
cp -r node_modules/@handsontable/hyperformula-skill ~/.claude/skills/hyperformula
```
Or project scope, from your project root:
```bash
cp -r node_modules/@handsontable/hyperformula-skill .claude/skills/hyperformula
```
### Claude API
Upload the package contents (`SKILL.md` + `references/`) to the [Skills API](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview).
### OpenAI Codex
Codex discovers [skills](https://developers.openai.com/codex/skills) as folders containing a `SKILL.md`, under `~/.agents/skills` (available in every repo) or `.agents/skills` in a repo (checked in for your team). Copy the package into one of those locations:
```bash
# User scope — available in every repo
cp -r node_modules/@handsontable/hyperformula-skill ~/.agents/skills/hyperformula
# Repo scope — from your project root
cp -r node_modules/@handsontable/hyperformula-skill .agents/skills/hyperformula
```
Codex also follows symlinked skill folders, so you can instead link the installed package and let `npm install` keep it current:
```bash
ln -s "$PWD/node_modules/@handsontable/hyperformula-skill" ~/.agents/skills/hyperformula
```
### Any other agent
Most coding agents support a skills, rules, or knowledge directory. Copy (or symlink) the package folder into it, for example:
```bash
# Replace <agent-skills-dir> with your agent's skills/knowledge directory
cp -r node_modules/@handsontable/hyperformula-skill <agent-skills-dir>/hyperformula
```
If your agent reads a single instruction file rather than a folder, point it at `SKILL.md`; it links the files under `references/` so the agent can pull them in as needed. For RAG / vector-store setups, ingest `SKILL.md` together with every file under `references/` (the repository also publishes a pre-flattened `hyperformula-rag.md` for this case).
## Other distribution formats
The same skill source is also shipped as a drag-and-drop `.zip` (Cowork / Claude.ai web), a flattened `-rag.md` doc (RAG / vector stores), and a Claude Code plugin marketplace entry. See the [repository README](https://github.com/handsontable/handsontable-skills#readme) for details.
## License
MIT — see the [repository](https://github.com/handsontable/handsontable-skills/blob/main/LICENSE.txt). HyperFormula itself is separately licensed (GPLv3 or commercial); see the [licensing guide](https://hyperformula.handsontable.com/docs/guide/licensing.html).
-134
View File
@@ -1,134 +0,0 @@
---
name: hyperformula
description: >
Use this skill when the user works with HyperFormula (HF) — a headless,
open-source TypeScript spreadsheet calculation engine. Use it when the user:
mentions HyperFormula or HF; wants to integrate Excel-like formulas into a
JS/TS app; evaluate formulas programmatically in a browser or Node.js;
simulate spreadsheet or Excel calculation behavior server-side; run
calculations over data parsed from XLSX files (e.g. via SheetJS) in code;
build a pricing engine, financial model, or what-if analysis in code; define
custom spreadsheet functions or named expressions outside of a UI context. Do
NOT use this skill for: general Excel questions unrelated to HF; general
Google Sheets questions; generic spreadsheet formula-syntax questions outside
an HF context; formulas used inside Handsontable (use the "handsontable"
skill instead).
---
# HyperFormula
HyperFormula is a **headless, open-source TypeScript spreadsheet calculation engine** for embedding spreadsheet logic in any JavaScript/TypeScript application (browser or Node.js). ~420 built-in functions, dependency graph, undo/redo, i18n (17 languages). Dual-licensed: GPLv3 or commercial.
- Docs: https://hyperformula.handsontable.com/
- npm: https://www.npmjs.com/package/hyperformula
- GitHub: https://github.com/handsontable/hyperformula
## Where to go next
Task-oriented references in `references/` (open the one that matches what the user is doing):
- [`getting-started.md`](references/getting-started.md) — how to install, create an instance (`buildFromArray` / `buildFromSheets` / `buildEmpty`), and work with cell addresses. Open this for onboarding questions and first-run errors.
- [`api-quickref.md`](references/api-quickref.md) — runnable examples for CRUD, rows/columns, sheets, exporting data, batching, named expressions, events, undo/redo, clipboard. Open this when implementing or debugging HF API calls.
- [`custom-functions.md`](references/custom-functions.md) — extending HyperFormula via `FunctionPlugin`: minimal plugin, argument types, volatile functions, range arguments, returning arrays, error handling, aliases, localized names. Open this when adding or debugging custom spreadsheet functions.
- [`configuration.md`](references/configuration.md) — `ConfigParams` options, Excel-compatibility preset, Google-Sheets preset, and config-related pitfalls (separator collisions, Node ICU, `precisionRounding` change in v3). Open this when tuning behavior or diagnosing locale issues.
- [`vue3.md`](references/vue3.md) — Vue 3 integration: `markRaw` requirement, Composition API, Pinia/Vuex stores, `destroy()` on unmount. Open this when embedding HF in a Vue 3 app.
- [`error-handling.md`](references/error-handling.md) — inspecting `CellError`, `ErrorType` enum, `getCellValueDetailedType`, common error causes (`#NAME?`, `#CYCLE!`, `#REF!`, …). Open this when a cell returns an error or you need to branch on result type.
- [`general-pitfalls.md`](references/general-pitfalls.md) — cross-cutting gotchas: `destroy()` lifecycle, forcing literal strings, Excel-parity caveats, hard limits. Open this when results look wrong or memory grows unboundedly.
Below this section is the **Documentation map** — the canonical directory of links to the official HyperFormula docs. Use it when pointing the user to authoritative material.
## Documentation map
All links resolve to `hyperformula.handsontable.com` unless noted.
### Introduction
- Welcome / Homepage: https://hyperformula.handsontable.com/
- Demo: https://hyperformula.handsontable.com/docs/guide/demo.html
### Overview
- Quality (test coverage, CI): https://hyperformula.handsontable.com/docs/guide/quality.html
- Supported browsers: https://hyperformula.handsontable.com/docs/guide/supported-browsers.html
- Dependencies: https://hyperformula.handsontable.com/docs/guide/dependencies.html
- Licensing (GPLv3 vs commercial): https://hyperformula.handsontable.com/docs/guide/licensing.html
- Support: https://hyperformula.handsontable.com/docs/guide/support.html
### Getting Started
- Client-side installation: https://hyperformula.handsontable.com/docs/guide/client-side-installation.html
- Server-side installation (Node.js): https://hyperformula.handsontable.com/docs/guide/server-side-installation.html
- Basic usage: https://hyperformula.handsontable.com/docs/guide/basic-usage.html
- Advanced usage: https://hyperformula.handsontable.com/docs/guide/advanced-usage.html
- Configuration options guide: https://hyperformula.handsontable.com/docs/guide/configuration-options.html
- License key setup: https://hyperformula.handsontable.com/docs/guide/license-key.html
### Framework Integration
- React: https://hyperformula.handsontable.com/docs/guide/integration-with-react.html
- Vue: https://hyperformula.handsontable.com/docs/guide/integration-with-vue.html
- Angular: https://hyperformula.handsontable.com/docs/guide/integration-with-angular.html
- Svelte: https://hyperformula.handsontable.com/docs/guide/integration-with-svelte.html
### Data Operations
- Basic operations (CRUD): https://hyperformula.handsontable.com/docs/guide/basic-operations.html
- Batch operations: https://hyperformula.handsontable.com/docs/guide/batch-operations.html
- Clipboard operations: https://hyperformula.handsontable.com/docs/guide/clipboard-operations.html
- Undo-redo: https://hyperformula.handsontable.com/docs/guide/undo-redo.html
- Sorting data: https://hyperformula.handsontable.com/docs/guide/sorting-data.html
### Formulas
- Specifications and limits: https://hyperformula.handsontable.com/docs/guide/specifications-and-limits.html
- Cell references (relative, absolute, mixed, cross-sheet): https://hyperformula.handsontable.com/docs/guide/cell-references.html
- Types of values: https://hyperformula.handsontable.com/docs/guide/types-of-values.html
- Types of errors: https://hyperformula.handsontable.com/docs/guide/types-of-errors.html
- Types of operators: https://hyperformula.handsontable.com/docs/guide/types-of-operators.html
- Order of precedence: https://hyperformula.handsontable.com/docs/guide/order-of-precendece.html
- Built-in functions (full list, ~400): https://hyperformula.handsontable.com/docs/guide/built-in-functions.html
- Volatile functions: https://hyperformula.handsontable.com/docs/guide/volatile-functions.html
- Named expressions: https://hyperformula.handsontable.com/docs/guide/named-expressions.html
- Array formulas / ARRAYFORMULA: https://hyperformula.handsontable.com/docs/guide/arrays.html
### Internationalization
- i18n features (17 languages): https://hyperformula.handsontable.com/docs/guide/i18n-features.html
- Localizing function names: https://hyperformula.handsontable.com/docs/guide/localizing-functions.html
- Date and time handling: https://hyperformula.handsontable.com/docs/guide/date-and-time-handling.html
### Compatibility
- Microsoft Excel compatibility: https://hyperformula.handsontable.com/docs/guide/compatibility-with-microsoft-excel.html
- Google Sheets compatibility: https://hyperformula.handsontable.com/docs/guide/compatibility-with-google-sheets.html
- Runtime differences (Excel & Sheets): https://hyperformula.handsontable.com/docs/guide/list-of-differences.html
### Advanced Topics
- Key concepts (AST, dependency graph, evaluation): https://hyperformula.handsontable.com/docs/guide/key-concepts.html
- Dependency graph: https://hyperformula.handsontable.com/docs/guide/dependency-graph.html
- Building & testing from source: https://hyperformula.handsontable.com/docs/guide/building.html
- Custom functions (FunctionPlugin): https://hyperformula.handsontable.com/docs/guide/custom-functions.html
- Performance: https://hyperformula.handsontable.com/docs/guide/performance.html
- Known limitations: https://hyperformula.handsontable.com/docs/guide/known-limitations.html
- File import: https://hyperformula.handsontable.com/docs/guide/file-import.html
### Upgrade & Migration
- Release notes / Changelog: https://hyperformula.handsontable.com/docs/guide/release-notes.html
- 0.6 → 1.0 migration: https://hyperformula.handsontable.com/docs/guide/migration-from-0.6-to-1.0.html
- 1.x → 2.0 migration: https://hyperformula.handsontable.com/docs/guide/migration-from-1.x-to-2.0.html
- 2.x → 3.0 migration: https://hyperformula.handsontable.com/docs/guide/migration-from-2.x-to-3.0.html
### API Reference
- API overview: https://hyperformula.handsontable.com/docs/api/
- HyperFormula class (all methods): https://hyperformula.handsontable.com/docs/api/classes/hyperformula.html
- ConfigParams interface (all options): https://hyperformula.handsontable.com/docs/api/interfaces/configparams.html
- Listeners interface (all events): https://hyperformula.handsontable.com/docs/api/interfaces/listeners.html
### npm
- npm page: https://www.npmjs.com/package/hyperformula
### Community & Support
- GitHub repo: https://github.com/handsontable/hyperformula
- GitHub Issues: https://github.com/handsontable/hyperformula/issues
- GitHub Discussions: https://github.com/handsontable/hyperformula/discussions
- Developer Forum: https://forum.handsontable.com/
- Commercial support: support@handsontable.com
- Sales (commercial license): sales@handsontable.com
- Contact form: https://handsontable.com/contact
---
> **Last updated: 2026-05-20 · Aligned with HyperFormula 3.3.0.**
> If the user is on a newer release, confirm API shape against the latest docs (see the **Release notes** link above) before relying on this file.
-34
View File
@@ -1,34 +0,0 @@
{
"name": "@handsontable/hyperformula-skill",
"version": "3.3.0",
"description": "Agent skill for HyperFormula — the headless, open-source TypeScript spreadsheet calculation engine. Teaches AI coding agents how to integrate, configure, and debug HyperFormula.",
"license": "MIT",
"author": "Handsontable <hello@handsontable.com> (https://handsontable.com)",
"homepage": "https://github.com/handsontable/handsontable-skills#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/handsontable/handsontable-skills.git",
"directory": "skills/hyperformula"
},
"bugs": {
"url": "https://github.com/handsontable/handsontable-skills/issues"
},
"keywords": [
"agent-skill",
"claude-skill",
"claude",
"anthropic",
"ai",
"hyperformula",
"spreadsheet",
"formula-engine",
"handsontable"
],
"files": [
"SKILL.md",
"references/"
],
"publishConfig": {
"access": "public"
}
}
@@ -1,147 +0,0 @@
# API Quick Reference
Runnable examples for the most-used HyperFormula APIs. Authoritative docs:
- Basic operations: https://hyperformula.handsontable.com/docs/guide/basic-operations.html
- Batch operations: https://hyperformula.handsontable.com/docs/guide/batch-operations.html
- Named expressions: https://hyperformula.handsontable.com/docs/guide/named-expressions.html
- Clipboard operations: https://hyperformula.handsontable.com/docs/guide/clipboard-operations.html
- Undo-redo: https://hyperformula.handsontable.com/docs/guide/undo-redo.html
- Sorting data: https://hyperformula.handsontable.com/docs/guide/sorting-data.html
- HyperFormula class (all methods): https://hyperformula.handsontable.com/docs/api/classes/hyperformula.html
- Listeners (all events): https://hyperformula.handsontable.com/docs/api/interfaces/listeners.html
## CRUD
```ts
// Write: string, number, or formula
hf.setCellContents({ sheet: 0, col: 0, row: 0 }, 'Hello');
hf.setCellContents({ sheet: 0, col: 1, row: 0 }, '=A1 & " World"');
// Read
hf.getCellValue({ sheet: 0, col: 1, row: 0 }); // computed result
hf.getCellFormula({ sheet: 0, col: 1, row: 0 }); // '=A1 & " World"'
hf.getCellType({ sheet: 0, col: 0, row: 0 }); // CellType.FORMULA | VALUE | EMPTY
```
## Rows and columns
Second arg is `[startIndex, count]`, not a list of indexes.
```ts
hf.addRows(sheetId, [rowIndex, numberOfRows]);
hf.removeRows(sheetId, [rowIndex, numberOfRows]);
hf.addColumns(sheetId, [colIndex, numberOfColumns]);
hf.removeColumns(sheetId, [colIndex, numberOfColumns]);
```
## Sheets
```ts
hf.addSheet('NewSheet');
hf.removeSheet(sheetId);
hf.renameSheet(sheetId, 'BetterName');
hf.countSheets();
hf.getSheetName(sheetId);
hf.getSheetId('SheetName');
hf.getSheetDimensions(sheetId); // → { width, height }
```
## Export data
```ts
hf.getSheetValues(sheetId); // computed values as 2D array
hf.getSheetSerialized(sheetId); // formulas/raw values as 2D array
hf.getAllSheetsValues(); // { SheetName: values[][] }
hf.getAllSheetsSerialized(); // { SheetName: formulas[][] }
```
## Batch operations
Every `setCellContents` call triggers a full dependency-graph recalculation. Always batch when writing more than one cell.
```ts
// Preferred: batch() — returns ExportedChange[]
const changes = hf.batch(() => {
hf.setCellContents({ sheet: 0, col: 0, row: 0 }, '100');
hf.setCellContents({ sheet: 0, col: 0, row: 1 }, '200');
hf.addRows(0, [2, 1]);
});
// `changes` contains all cells that were recalculated.
// For bulk imports (value writes only):
hf.suspendEvaluation();
// ... hundreds of setCellContents calls ...
hf.resumeEvaluation(); // single recalc
// PITFALL: structural ops (addRows/moveRows/...) during suspendEvaluation
// have degraded performance. Use batch() when mixing structural + value writes.
```
## Sorting
`setRowOrder` takes a **permutation array**, not a comparator. Compute the new index sequence externally, then pass it in.
```ts
// Sort rows by the value in column 0
const rowCount = hf.getSheetDimensions(0).height;
const indexes = Array.from({ length: rowCount }, (_, i) => i);
indexes.sort((a, b) => {
const va = hf.getCellValue({ sheet: 0, col: 0, row: a }) as number;
const vb = hf.getCellValue({ sheet: 0, col: 0, row: b }) as number;
return va - vb;
});
hf.setRowOrder(0, indexes); // e.g. [2, 0, 1, 3]
```
## Named expressions
Reusable names for values or formulas. Can be global or scoped to a sheet (local shadows global).
```ts
hf.addNamedExpression('TAX_RATE', '0.21');
hf.addNamedExpression('TOTAL_REVENUE', '=SUM(Revenue!A:A)');
hf.setCellContents({ sheet: 0, col: 0, row: 0 }, '=1000 * TAX_RATE');
hf.listNamedExpressions(); // all registered names
```
## Custom functions
See [custom-functions.md](custom-functions.md) — `FunctionPlugin`, argument types, volatile functions, range arguments, returning arrays, error handling, aliases, localized names.
## Events
Subscribe via `.on()` on the instance. Full list: https://hyperformula.handsontable.com/docs/api/interfaces/listeners.html
```ts
hf.on('valuesUpdated', (changes) => {
changes.forEach((c) => console.log(c.address, c.newValue));
});
hf.on('sheetAdded', (addedSheetName) => { /* ... */ });
hf.on('sheetRemoved', (removedSheetName, removedSheetData) => { /* ... */ });
hf.on('sheetRenamed', (oldName, newName) => { /* ... */ });
```
## Undo / redo
```ts
hf.undo();
hf.redo();
hf.isThereSomethingToUndo();
hf.isThereSomethingToRedo();
hf.clearUndoStack();
```
Configure depth via `undoLimit` in `ConfigParams` (see [configuration.md](configuration.md)).
## Clipboard
```ts
hf.copy({ start: { sheet: 0, col: 0, row: 0 }, end: { sheet: 0, col: 1, row: 1 } });
hf.cut ({ start: { sheet: 0, col: 0, row: 0 }, end: { sheet: 0, col: 1, row: 1 } });
hf.paste({ sheet: 0, col: 5, row: 5 });
hf.isClipboardEmpty();
hf.clearClipboard();
```
@@ -1,92 +0,0 @@
# Configuration
`ConfigParams` + compatibility presets + config-specific pitfalls. Authoritative docs:
- Configuration options guide: https://hyperformula.handsontable.com/docs/guide/configuration-options.html
- ConfigParams (all options): https://hyperformula.handsontable.com/docs/api/interfaces/configparams.html
- Excel compatibility: https://hyperformula.handsontable.com/docs/guide/compatibility-with-microsoft-excel.html
- Google Sheets compatibility: https://hyperformula.handsontable.com/docs/guide/compatibility-with-google-sheets.html
## ConfigParams highlights
Pass as the second argument to any factory method.
```ts
const hf = HyperFormula.buildEmpty({
licenseKey: 'gpl-v3', // required — 'gpl-v3' for open source
precisionRounding: 10, // decimal rounding precision (default 10; was 14 before v3)
functionArgSeparator: ',', // separator between function arguments
dateFormats: ['DD/MM/YYYY', 'DD-MM-YYYY'],
nullDate: { year: 1900, month: 1, day: 1 },
leapYear1900: false, // Excel bug compat: treat 1900 as leap year
maxRows: 40000, // max rows per sheet (default 40000)
maxColumns: 18278, // max columns per sheet (default 18278 = 'ZZZ')
undoLimit: 20, // undo history depth
maxPendingLazyTransformations: 1000, // v3.3+ — cap accumulated lazy transformations before cleanup
});
```
## Excel compatibility preset
```ts
const hf = HyperFormula.buildEmpty({
licenseKey: 'gpl-v3',
useArrayArithmetic: true,
useWildcards: true,
evaluateNullToZero: true,
leapYear1900: true, // Excel's intentional 1900 leap-year bug
ignoreWhiteSpace: 'any',
caseSensitive: false,
accentSensitive: true,
});
```
## Google Sheets compatibility preset
Defaults already match Google Sheets behavior (`leapYear1900: false`, `useArrayArithmetic: false`).
```ts
const hf = HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' });
```
## Config-specific pitfalls
### Vue 3
Vue 3 requires wrapping the instance with `markRaw` — see [vue3.md](vue3.md) for the full integration guide (React / Angular / Svelte need no special handling).
### `functionArgSeparator` vs `thousandSeparator` collision
These two options **cannot share a character**. European locales that use `,` as thousand separator must change the argument separator:
```ts
const hf = HyperFormula.buildEmpty({
licenseKey: 'gpl-v3',
thousandSeparator: ',',
decimalSeparator: '.',
functionArgSeparator: ';', // must differ from thousandSeparator
});
```
### Node.js must have full ICU
String comparisons (used in `MATCH`, `VLOOKUP`, sorting) can silently produce wrong results on Node.js < 13 or builds without full ICU. Verify at startup:
```ts
// Must report 'full' (or a detailed ICU version), not 'small'.
console.log(process.versions.icu);
```
### Tuning `maxPendingLazyTransformations` (v3.3+)
HyperFormula defers some structural transformations (row/column insertions, moves) and applies them lazily. In long-lived instances with heavy mutation throughput — bulk imports, frequent undo/redo, scripted batch edits — the pending queue can grow before cleanup. v3.3 fixed the unbounded-growth leak; this option lets you cap the queue explicitly. Lower values trade memory for more frequent flush work; the default is suitable for typical UI workloads.
### `precisionRounding` default changed in v3
Before v3 the default was `14`. It is now `10`. Calculations relying on the old precision need an explicit override:
```ts
const hf = HyperFormula.buildEmpty({
licenseKey: 'gpl-v3',
precisionRounding: 14, // pre-v3 behavior
});
```
@@ -1,174 +0,0 @@
# Custom Functions
Extend HyperFormula with your own functions via `FunctionPlugin`. Authoritative doc:
- Custom functions guide: https://hyperformula.handsontable.com/docs/guide/custom-functions.html
## Minimal plugin
Register **before** creating any instance — functions registered after `buildFromArray` / `buildFromSheets` / `buildEmpty` won't be available.
```ts
import { HyperFormula, FunctionPlugin, FunctionArgumentType } from 'hyperformula';
class MyPlugin extends FunctionPlugin {
greet(ast, state) {
return this.runFunction(
ast.args,
state,
this.metadata('GREET'),
(name) => `Hello, ${name}!`
);
}
}
MyPlugin.implementedFunctions = {
GREET: {
method: 'greet',
parameters: [{ argumentType: FunctionArgumentType.STRING }],
},
};
const translations = { enGB: { GREET: 'GREET' }, enUS: { GREET: 'GREET' } };
// PITFALL: registration MUST happen before any instance is built.
HyperFormula.registerFunctionPlugin(MyPlugin, translations);
const hf = HyperFormula.buildFromArray(
[['World', '=GREET(A1)']],
{ licenseKey: 'gpl-v3' }
);
console.log(hf.getCellValue({ sheet: 0, col: 1, row: 0 })); // "Hello, World!"
```
## Volatile functions
Mark a function as volatile so it recalculates on every change (like `RAND` or `NOW`). Volatile functions are expensive in large sheets — use sparingly.
```ts
MyPlugin.implementedFunctions = {
RAND_ID: {
method: 'randId',
parameters: [],
isVolatile: true,
},
};
```
## Argument types
```ts
import { FunctionArgumentType } from 'hyperformula';
// FunctionArgumentType.NUMBER
// FunctionArgumentType.STRING
// FunctionArgumentType.BOOLEAN
// FunctionArgumentType.NOERROR
// FunctionArgumentType.SCALAR
// FunctionArgumentType.RANGE // accepts a cell range
// FunctionArgumentType.ANY
```
Use `optionalArg: true`, `defaultValue`, and `minValue` / `maxValue` to validate arguments declaratively:
```ts
MyPlugin.implementedFunctions = {
CLAMP: {
method: 'clamp',
parameters: [
{ argumentType: FunctionArgumentType.NUMBER },
{ argumentType: FunctionArgumentType.NUMBER, optionalArg: true, defaultValue: 0 },
{ argumentType: FunctionArgumentType.NUMBER, optionalArg: true, defaultValue: 100 },
],
},
};
```
## Range arguments
Use `RANGE` argument type when the function takes a cell area. The callback receives the range as a 2D array.
```ts
class SumPositivesPlugin extends FunctionPlugin {
sumPositives(ast, state) {
return this.runFunction(
ast.args,
state,
this.metadata('SUM_POSITIVES'),
(range) => {
let total = 0;
for (const row of range.data) {
for (const v of row) if (typeof v === 'number' && v > 0) total += v;
}
return total;
}
);
}
}
SumPositivesPlugin.implementedFunctions = {
SUM_POSITIVES: {
method: 'sumPositives',
parameters: [{ argumentType: FunctionArgumentType.RANGE }],
},
};
```
## Returning arrays
Custom functions can return 2D arrays. **PITFALL:** result arrays don't auto-resize when upstream dependencies change — the footprint is fixed at first evaluation.
```ts
class SplitPlugin extends FunctionPlugin {
splitToRow(ast, state) {
return this.runFunction(
ast.args,
state,
this.metadata('SPLIT_ROW'),
(text, sep) => [text.split(sep)] // 2D array: one row, N cols
);
}
}
```
## Error handling
Return a `CellError` to signal a formula error:
```ts
import { CellError, ErrorType } from 'hyperformula';
class DividePlugin extends FunctionPlugin {
safeDivide(ast, state) {
return this.runFunction(
ast.args,
state,
this.metadata('SAFE_DIVIDE'),
(a, b) => (b === 0 ? new CellError(ErrorType.DIV_BY_ZERO) : a / b)
);
}
}
```
## Aliases and localized names
Expose one method under multiple function names, or localize:
```ts
MyPlugin.aliases = {
HELLO: 'GREET', // calling =HELLO(x) runs the greet() method
};
const translations = {
enGB: { GREET: 'GREET' },
plPL: { GREET: 'POWITAJ' }, // =POWITAJ("Świat") in Polish
};
HyperFormula.registerFunctionPlugin(MyPlugin, translations);
```
## Unregister / introspect
```ts
HyperFormula.unregisterFunctionPlugin(MyPlugin);
HyperFormula.unregisterFunction('GREET');
HyperFormula.getRegisteredFunctionNames('enGB');
```
@@ -1,69 +0,0 @@
# Error Handling
Inspect cell values that may be errors, tell error types apart, and check value shape. Authoritative docs:
- Types of errors: https://hyperformula.handsontable.com/docs/guide/types-of-errors.html
- Types of values: https://hyperformula.handsontable.com/docs/guide/types-of-values.html
## Always check `CellError` before using a result
Cell values can be errors (`#DIV/0!`, `#VALUE!`, `#REF!`, etc.). Always test before using a result.
```ts
import { CellError, ErrorType } from 'hyperformula';
const value = hf.getCellValue({ sheet: 0, col: 0, row: 0 });
if (value instanceof CellError) {
// ErrorType enum: DIV_BY_ZERO, VALUE, REF, NAME, NUM, NA, CYCLE, ERROR
switch (value.type) {
case ErrorType.CYCLE:
console.log('Circular reference');
break;
case ErrorType.NAME:
// Usually: function not registered (check plugin registration or i18n language)
console.log('Unknown name:', value.message);
break;
default:
console.log('Error:', value.type, value.message);
}
} else {
console.log('Value:', value);
}
```
## `#CYCLE!` is HyperFormula-specific
Standard spreadsheet apps report cycles differently. HyperFormula's `IF` also reports cycles for all branches, even unreachable ones — this can produce `#CYCLE!` in formulas that Excel or Sheets would evaluate.
## Inspect value shape without catching errors
```ts
import { CellValueDetailedType } from 'hyperformula';
hf.getCellValueDetailedType({ sheet: 0, col: 0, row: 0 });
// → CellValueDetailedType.NUMBER | STRING | BOOLEAN | ERROR | EMPTY | ...
```
Use the detailed type when you need to distinguish e.g. number vs empty without touching the value.
## Returning errors from custom functions
Custom `FunctionPlugin` methods can return a `CellError` to surface a formula error. See [custom-functions.md](custom-functions.md) for the full pattern.
```ts
import { CellError, ErrorType } from 'hyperformula';
return new CellError(ErrorType.DIV_BY_ZERO);
```
## Common causes
| Error | Common cause |
|---|---|
| `#NAME?` | Function not registered (check `registerFunctionPlugin` order or `language` config) |
| `#CYCLE!` | Circular reference — or an `IF` branch that *could* produce one |
| `#REF!` | Deleted row/column broke a formula reference |
| `#VALUE!` | Type mismatch — e.g. passing a string where a number is expected |
| `#DIV/0!` | Division by zero, including empty cells coerced to 0 |
| `#NUM!` | Out-of-range numeric result (e.g. `SQRT(-1)`) |
| `#N/A` | Lookup miss (`MATCH`, `VLOOKUP`) or propagated from an upstream `#N/A` |
@@ -1,48 +0,0 @@
# General Pitfalls
Cross-cutting gotchas that aren't tied to a specific API or config option. Authoritative docs:
- Known limitations: https://hyperformula.handsontable.com/docs/guide/known-limitations.html
- Built-in functions (full list): https://hyperformula.handsontable.com/docs/guide/built-in-functions.html
- Runtime differences vs Excel/Sheets: https://hyperformula.handsontable.com/docs/guide/list-of-differences.html
## Error handling
See [error-handling.md](error-handling.md) — checking `CellError`, `ErrorType` enum, `getCellValueDetailedType`, common error causes.
## Always call `destroy()` in long-running apps
HyperFormula maintains internal data structures (dependency graph, address mapping) that are **not** garbage-collected until `destroy()` is called. Leaking instances in servers or SPAs accumulates memory.
```ts
hf.destroy();
// After destroy() the instance is unusable — create a new one if needed.
```
v3.3 fixed two longstanding leak sources inside live instances — pending lazy transformations and undo/redo history were not being trimmed. If you maintain very long-lived instances with heavy mutation throughput, also see `maxPendingLazyTransformations` in [configuration.md](configuration.md) to bound the lazy-transformation queue. `destroy()` is still mandatory at teardown.
## Force a string that looks like a formula
Prefix with `'` (apostrophe) to store the literal text instead of evaluating.
```ts
// Stored as the literal string "=SUM(1,2)", not a formula:
hf.setCellContents({ sheet: 0, col: 0, row: 0 }, "'=SUM(1,2)");
```
## Don't assume Excel parity
~68% of Excel functions are covered. Runtime differences exist even for implemented functions. Before relying on behavior, check:
- Full built-in list: https://hyperformula.handsontable.com/docs/guide/built-in-functions.html
- Runtime differences: https://hyperformula.handsontable.com/docs/guide/list-of-differences.html
## `licenseKey` is always required
Every factory method (`buildFromArray`, `buildFromSheets`, `buildEmpty`) requires `licenseKey`. Use `'gpl-v3'` for open-source use or your commercial key.
## Known hard limits
- **Single workbook per instance** — no multi-workbook support.
- No 3D references, dynamic arrays, async functions, structured references ("Tables"), or relative named expressions.
- `IF` reports cycles for all branches, even unreachable ones.
- Custom function result arrays don't auto-resize when dependencies change.
@@ -1,82 +0,0 @@
# Getting Started
Install, create an instance, read/write cells. Authoritative docs:
- Client-side install: https://hyperformula.handsontable.com/docs/guide/client-side-installation.html
- Server-side install: https://hyperformula.handsontable.com/docs/guide/server-side-installation.html
- Basic usage: https://hyperformula.handsontable.com/docs/guide/basic-usage.html
## Install
### npm
```bash
npm install hyperformula
```
### CDN (jsDelivr, pinned version)
```html
<script src="https://cdn.jsdelivr.net/npm/hyperformula@3.2.0/dist/hyperformula.full.min.js"></script>
```
### Node.js
Same npm install. Requires Node.js 13+ with full ICU for locale-aware string comparison.
## Create an instance
Every factory method requires `licenseKey`. Use `'gpl-v3'` for open-source use or your commercial key.
### `buildFromArray` — single sheet from a 2D array
```ts
import { HyperFormula } from 'hyperformula';
const hf = HyperFormula.buildFromArray(
[
['10', '20', '=SUM(A1:B1)'],
['30', '40', '=SUM(A2:B2)'],
],
{ licenseKey: 'gpl-v3' }
);
console.log(hf.getCellValue({ sheet: 0, col: 2, row: 0 })); // 30
```
### `buildFromSheets` — multi-sheet workbook
```ts
const hf = HyperFormula.buildFromSheets(
{
Revenue: [['100', '200', '=SUM(A1:B1)']],
Expenses: [['50', '=Revenue!C1 - A1']],
},
{ licenseKey: 'gpl-v3' }
);
```
Cross-sheet references use `SheetName!CellRef` syntax.
### `buildEmpty` — start empty, add sheets later
```ts
const hf = HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' });
const sheetName = hf.addSheet('Data');
const sheetId = hf.getSheetId(sheetName);
hf.setCellContents({ sheet: sheetId, col: 0, row: 0 }, [
['10', '20', '=SUM(A1:B1)'],
]);
```
## Cell addresses
All addresses use zero-indexed `{ sheet, col, row }`. Convert to/from A1 notation with built-in helpers:
```ts
hf.simpleCellAddressFromString('B3', 0);
// → { sheet: 0, col: 1, row: 2 }
hf.simpleCellAddressToString({ sheet: 0, col: 1, row: 2 }, 0);
// → 'B3'
```
@@ -1,72 +0,0 @@
# Vue 3
HyperFormula + Vue 3 integration. Authoritative doc:
- Vue integration guide: https://hyperformula.handsontable.com/docs/guide/integration-with-vue.html
## Always wrap the instance with `markRaw`
Vue 3's Composition API wraps objects in a reactive Proxy that intercepts property access and **corrupts HyperFormula's internal state**, causing silent data corruption or crashes. React, Angular, and Svelte need no special handling.
```ts
import { markRaw } from 'vue';
import { HyperFormula } from 'hyperformula';
const hf = markRaw(
HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' })
);
```
Use the same wrapper no matter where the instance is held — `ref`, `reactive`, component state, a store (Pinia/Vuex), or a composable. If the raw HyperFormula instance ever reaches Vue's reactivity system, it must be marked.
## Composition API example
```ts
import { onBeforeUnmount, ref } from 'vue';
import { markRaw } from 'vue';
import { HyperFormula } from 'hyperformula';
export function useHyperFormula() {
const hf = markRaw(
HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' })
);
// Free internal data structures when the component unmounts.
onBeforeUnmount(() => hf.destroy());
const result = ref<unknown>(null);
function evaluate(formula: string) {
hf.setCellContents({ sheet: 0, col: 0, row: 0 }, formula);
result.value = hf.getCellValue({ sheet: 0, col: 0, row: 0 });
}
return { evaluate, result };
}
```
## Pinia / Vuex store
```ts
import { defineStore } from 'pinia';
import { markRaw } from 'vue';
import { HyperFormula } from 'hyperformula';
export const useCalcStore = defineStore('calc', {
state: () => ({
hf: markRaw(
HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' })
),
}),
});
```
## Cleanup
HyperFormula's internal graph is **not** garbage-collected until `destroy()` is called. In SPAs this accumulates memory over time.
```ts
import { onBeforeUnmount } from 'vue';
onBeforeUnmount(() => hf.destroy());
// After destroy() the instance is unusable — create a new one if needed.
```
+5 -5
View File
@@ -38,13 +38,13 @@ jobs:
npm ci
- name: Check audit
# Audit should fail and stop the CI on any vulnerability in root and sas, and on low+ in client
# Audit should fail and stop the CI if critical vulnerability found
run: |
npm audit --omit=dev
npm audit --audit-level=critical --omit=dev
cd ./sas
npm audit --omit=dev
npm audit --audit-level=critical --omit=dev
cd ../client
npm audit --omit=dev
npm audit --audit-level=critical --omit=dev
- name: Lint check
run: npm run lint:check
@@ -146,7 +146,7 @@ jobs:
# Start frontend and run cypress
# timeout 1800: SIGTERM after 30 min so Cypress can flush video/screenshots
# before the outer timeout-minutes hard-kills the step (avoids silent multi-hour hangs)
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && timeout 1800 npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.cy.ts,cypress/e2e/viewbox.cy.ts"
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && timeout 1800 npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts"
- name: Zip Cypress videos
if: always()
+1 -1
View File
@@ -58,7 +58,7 @@ jobs:
- name: Update appLoc in index.html
run: |
cd client
replace-in-files --regex='appLoc=".*?"' --replacement='appLoc="/proj/sasjs/genesis-mocks"' ./src/index.html
replace-in-files --regex='appLoc=".*?"' --replacement='appLoc="/Public/app/devtest"' ./src/index.html
- name: Build Frontend
run: |
+18 -80
View File
@@ -45,13 +45,13 @@ jobs:
npm ci
- name: Check audit
# Audit should fail and stop the CI on any vulnerability in root and sas, and on low+ in client
# Audit should fail and stop the CI if critical vulnerability found
run: |
npm audit --omit=dev
cd ./sas
npm audit --omit=dev
cd ../client
npm audit --omit=dev
npm audit --audit-level=critical --omit=dev
- name: Angular Tests
run: |
@@ -143,7 +143,7 @@ jobs:
replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts
cat ./cypress.config.ts
# Start frontend and run cypress
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.cy.ts"
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts"
- name: Zip Cypress videos
if: always()
@@ -161,16 +161,6 @@ jobs:
release:
runs-on: ubuntu-latest
needs: [Build-production-and-ng-test, Build-and-test-development]
# The semantic-release step below uses the per-job token injected by Gitea
# Actions (secrets.GITEA_TOKEN). That token is scoped to THIS repository
# and its permissions are derived from this block.
# contents: write -> push CHANGELOG.md / package.json commit + tag, and
# create the Release via the Gitea API.
# The TSDOC_TOKEN and CODE_DATACONTROLLER_IO secrets continue to be used
# because they target *different* servers (webdoc.datacontroller.io and
# code.datacontroller.io), not the Gitea API.
permissions:
contents: write
steps:
- uses: actions/checkout@v4
@@ -203,41 +193,12 @@ jobs:
npm run build
- name: Create Empty Release (assets are posted later)
env:
# Use the ephemeral per-job token injected by Gitea Actions instead
# of a manually-managed PAT. This token always reports push=true on
# its own repo via the Gitea API, which is what the
# @saithodev/semantic-release-gitea verifyConditions step checks.
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_URL: ${{ gitea.server_url }}
run: |
set -euo pipefail
npm i
npm i -g semantic-release
# We do a semantic-release DRY RUN to make the job fail if there are
# no changes to release. The previous implementation piped --dry-run
# into `grep`, which swallowed semantic-release's non-zero exit codes
# (e.g. auth/verifyConditions failures) — the real error then only
# surfaced from the second invocation. Capture output and inspect it
# so authentication failures fail the step cleanly with context.
echo "::group::semantic-release --dry-run"
DRY_EXIT=0
DRY_OUTPUT=$(semantic-release --dry-run 2>&1) || DRY_EXIT=$?
echo "$DRY_OUTPUT"
echo "::endgroup::"
if [ "$DRY_EXIT" -ne 0 ]; then
echo "::error::semantic-release dry-run failed (exit $DRY_EXIT). See log above; common cause is the Gitea token lacking push permission on this repo (verifyConditions of @saithodev/semantic-release-gitea)."
exit "$DRY_EXIT"
fi
if echo "$DRY_OUTPUT" | grep -q "There are no relevant changes, so no new version is released."; then
echo "::error::No releasable changes since last tag - aborting release job."
exit 1
fi
semantic-release
# We do a semantic-release DRY RUN to make the job fail if there are no changes to release
GITEA_TOKEN=${{ secrets.RELEASE_TOKEN }} GITEA_URL=https://git.datacontroller.io semantic-release --dry-run | grep -q "There are no relevant changes, so no new version is released." && exit 1
GITEA_TOKEN=${{ secrets.RELEASE_TOKEN }} GITEA_URL=https://git.datacontroller.io semantic-release
- name: Frontend Build
description: Must be created AFTER the release as the version (git tag) is used in the interface
@@ -311,40 +272,17 @@ jobs:
surfer put --token ${{ secrets.CODE_DATACONTROLLER_IO }} --server code.datacontroller.io sasjsbuild/sasdocs/* /
- name: Upload assets to release
env:
# Use the same ephemeral per-job Gitea Actions token that the
# "Create Empty Release" step uses for semantic-release. It is scoped
# to this repo and is granted write access via the workflow's
# `permissions:` block at the top of the file (see contents: write).
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
# Send the token via Authorization header rather than ?access_token=
# (the query-string form is deprecated and leaks into access logs).
AUTH_HEADER="Authorization: token ${GITEA_TOKEN}"
BASE="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
RELEASE_JSON=$(curl -k --fail-with-body -sS -H "$AUTH_HEADER" "$BASE/releases/latest")
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id')
RELEASE_BODY=$(echo "$RELEASE_JSON" | jq -r '.body')
# Update body (also confirms the token has contents:write on this repo)
curl -k --fail-with-body -sS -X PATCH \
-H "$AUTH_HEADER" \
-H 'Content-Type: application/json' \
--data "$(jq -n --arg body "$RELEASE_BODY"$'\n\nFor installation instructions, please visit https://docs.datacontroller.io/' \
'{draft:false, body:$body}')" \
"$BASE/releases/$RELEASE_ID"
RELEASE_ID=`curl -k 'https://git.datacontroller.io/api/v1/repos/dc/dc/releases/latest?access_token=${{ secrets.RELEASE_TOKEN }}' | jq -r '.id'`
RELEASE_BODY=`curl -k 'https://git.datacontroller.io/api/v1/repos/dc/dc/releases/latest?access_token=${{ secrets.RELEASE_TOKEN }}' | jq -r '.body'`
# Update body
curl --data '{"draft": false,"body":"'"$RELEASE_BODY\n\nFor installation instructions, please visit https://docs.datacontroller.io/"'"}' -X PATCH --header 'Content-Type: application/json' -k https://git.datacontroller.io/api/v1/repos/dc/dc/releases/$RELEASE_ID?access_token=${{ secrets.RELEASE_TOKEN }}
# Upload assets
URL="$BASE/releases/$RELEASE_ID/assets"
for f in frontend.zip \
sas/demostream_sas9.sas \
sas/viya.sas \
sas/sasjs_server.json.zip \
sas/sas9.sas \
sas/viya_noweb.sas \
sas/viya_noweb.json; do
echo "Uploading $f ..."
curl -k --fail-with-body -sS -H "$AUTH_HEADER" "$URL" -F "attachment=@$f"
done
URL="https://git.datacontroller.io/api/v1/repos/dc/dc/releases/$RELEASE_ID/assets?access_token=${{ secrets.RELEASE_TOKEN }}"
curl -k $URL -F attachment=@frontend.zip
curl -k $URL -F attachment=@sas/demostream_sas9.sas
curl -k $URL -F attachment=@sas/viya.sas
curl -k $URL -F attachment=@sas/sasjs_server.json.zip
curl -k $URL -F attachment=@sas/sas9.sas
curl -k $URL -F attachment=@sas/viya_noweb.sas
curl -k $URL -F attachment=@sas/viya_noweb.json
-1
View File
@@ -1,6 +1,5 @@
{
"cSpell.words": [
"Handsontable",
"Licence",
"SYSERRORTEXT",
"SYSWARNINGTEXT",
-21
View File
@@ -1,21 +0,0 @@
# Agent Instructions
## Git
Do NOT auto-commit. Never run `git commit` (or `git push`) unless the user explicitly asks. Leave changes in the working tree for the user to review and commit themselves.
## Markdown files
Do NOT hard-wrap Markdown files (no fixed-width line wrapping / carriage returns inside paragraphs). Each paragraph, list item, and heading should be a single logical line, regardless of length. Let the editor/viewer soft-wrap. This includes AGENTS.md itself.
This is different from `.sas` files, where a maximum line length applies. The no-wrap rule applies to all `*.md` files in this repo (docs, READMEs, etc.).
Rationale: hard-wrapped prose produces noisy diffs when sentences are edited and reflowed, and Markdown renderers already handle wrapping.
## SAS files
After creating or modifying any `.sas` files, run `sasjs lint` from the `sas/` directory and ensure the files you touched have no lint warnings (the repo currently has pre-existing warnings in other files, which can be ignored).
## The .agent folder
Agent-related content lives in `.agent/`: technical/agent-facing documentation goes in `.agent/docs/` (not `docs/`), and skills in `.agent/skills/`. When writing explanatory or technical docs about the codebase, put them in `.agent/docs/`.
-110
View File
@@ -1,113 +1,3 @@
# [7.11.0](https://git.datacontroller.io/dc/dc/compare/v7.10.1...v7.11.0) (2026-07-20)
### Bug Fixes
* audit-skipping syntax correction ([ad7efc9](https://git.datacontroller.io/dc/dc/commit/ad7efc90bc33c93383e17359d14c0605cded69a9))
* **editor:** ignore cell validation on rows marked for delete ([70c5805](https://git.datacontroller.io/dc/dc/commit/70c5805743c8c6e61bc216f37cbe8cd4f0614c98))
* explicit use of .sashdat in dc_casload ([6a95067](https://git.datacontroller.io/dc/dc/commit/6a95067618600d7b03b6af46bf3d74721e42c66a))
* refactor mf_wordsinstr1butnotstr2 and added a wide table to test suite ([be94c68](https://git.datacontroller.io/dc/dc/commit/be94c68f354d0f9b1eb52213807d1874a74d9b8a))
* retry the startup service request once before failing ([5391df8](https://git.datacontroller.io/dc/dc/commit/5391df8f04cf40205a600b23c207f97b6fdf5913))
* run dc_casload for dq tables in getdata. Closes [#264](https://git.datacontroller.io/dc/dc/issues/264). ([6b16f9b](https://git.datacontroller.io/dc/dc/commit/6b16f9b3647fca2e39d44544201ce972046e01a3))
* updating tests, moving mpe_rowhash to core library ([c5f0e4f](https://git.datacontroller.io/dc/dc/commit/c5f0e4f106aa4e6a7c2a2fd0ebb2e85d1e7bee7b))
### Features
* bumping core to get the mp_rowhash definition, [#269](https://git.datacontroller.io/dc/dc/issues/269) ([f73f5e4](https://git.datacontroller.io/dc/dc/commit/f73f5e47ac38a305bac4ebd7eac48d1a3edf4a83))
* new hash algorithm - allows 1000+ cols. Closes [#269](https://git.datacontroller.io/dc/dc/issues/269) ([08947f0](https://git.datacontroller.io/dc/dc/commit/08947f0fc8458be098e3aaf769b1da7fadf480cb))
## [7.10.1](https://git.datacontroller.io/dc/dc/compare/v7.10.0...v7.10.1) (2026-07-15)
### Bug Fixes
* **editor:** order validation rules by COLHEADERS, not cols[].VARNUM ([4c2aca3](https://git.datacontroller.io/dc/dc/commit/4c2aca34951d6d5c5cf2df69d5bf4256a9290d53))
# [7.10.0](https://git.datacontroller.io/dc/dc/compare/v7.9.1...v7.10.0) (2026-07-13)
### Bug Fixes
* adapt column validation to vertical-array COLTYPE ([#253](https://git.datacontroller.io/dc/dc/issues/253)) ([3016750](https://git.datacontroller.io/dc/dc/commit/301675052fb5cbcb11f54c370ad7eda1d60461fd))
* backend updates to bring back labels under [#240](https://git.datacontroller.io/dc/dc/issues/240) ([fb94840](https://git.datacontroller.io/dc/dc/commit/fb94840016f067fa3523a3539dd0616d8970eed0))
* **editor:** normalise unpadded time strings on spreadsheet import ([7b4b4eb](https://git.datacontroller.io/dc/dc/commit/7b4b4ebeeb0e34ec5fcd5c485741d7bbddb43b7e))
* **hot:** row rendering on resize, resolve blank whitespace ([c7ba025](https://git.datacontroller.io/dc/dc/commit/c7ba025d39aa9681ad51f239503e884fb26613e2))
* including datadictionary descs in labels, [#240](https://git.datacontroller.io/dc/dc/issues/240) ([e7453a9](https://git.datacontroller.io/dc/dc/commit/e7453a9305263faf4d4f288d50977121353040c7))
* pass variable formats to front-end in a vertical array instead of horizontal list ([691d6f2](https://git.datacontroller.io/dc/dc/commit/691d6f277ea87051f539681b34da2f39cade8fc3))
* remove coltype from sasparams table, add it to cols table. ([8101400](https://git.datacontroller.io/dc/dc/commit/81014001ac4da6b8d262c72c65e0c657f186ec84))
* trim leading/trailing whitespace from Excel header cells on upload ([2728dac](https://git.datacontroller.io/dc/dc/commit/2728dac873ab8c9fc44413f873e4730515ca0e21))
* **va:** date filters refactor, support more dates ([df5c975](https://git.datacontroller.io/dc/dc/commit/df5c9758697d7481e67d20ed7ff81e9ce4e92ec0))
* **va:** enable readOnly and hold filtering while editing ([82a254d](https://git.datacontroller.io/dc/dc/commit/82a254d22cb7aaf75aae7b332f0b07fc8bda3e44))
* **va:** enable Upload button in va mode ([1beb3d4](https://git.datacontroller.io/dc/dc/commit/1beb3d490d0e7b8d5fa7985f121495ed62740cfe))
* **va:** guard deferred contextMenu toggle against destroyed instance ([b5e9b25](https://git.datacontroller.io/dc/dc/commit/b5e9b2531924bec1a23c67d3ad0ca3f512a75655))
### Features
* adding DC_MAXOBS_WEBVIEW config item, closes [#258](https://git.datacontroller.io/dc/dc/issues/258) ([ba7b610](https://git.datacontroller.io/dc/dc/commit/ba7b61082d39767f9539a84de6742a1704573e81))
* viewer/editor column label display toggle (?labels=true) ([25c12f2](https://git.datacontroller.io/dc/dc/commit/25c12f2b18adda09e6edc93eba263bd5ea0f7409))
### Reverts
* enable upload button in va mode ([0d7fd34](https://git.datacontroller.io/dc/dc/commit/0d7fd342970744489a2e6c6fb08f239fe853cb81))
## [7.9.1](https://git.datacontroller.io/dc/dc/compare/v7.9.0...v7.9.1) (2026-06-30)
### Bug Fixes
* workflow file upload using action PAT ([a161670](https://git.datacontroller.io/dc/dc/commit/a161670b8656614f61d42ee673064ba8f010b71c))
# [7.9.0](https://git.datacontroller.io/dc/dc/compare/v7.8.2...v7.9.0) (2026-06-29)
### Bug Fixes
* allow large retained key values. Closes [#248](https://git.datacontroller.io/dc/dc/issues/248). ([6a30de3](https://git.datacontroller.io/dc/dc/commit/6a30de37cad21b7b94bf550ce7afbc804e6f0f90))
* build issues ([#245](https://git.datacontroller.io/dc/dc/issues/245)) ([77b4bb3](https://git.datacontroller.io/dc/dc/commit/77b4bb310e62adb450805488fc69a132435d0eb7))
* **ci:** bump Lighthouse to 13.4.0 ([47ba03e](https://git.datacontroller.io/dc/dc/commit/47ba03ec637113d8867372d64484927de9b645ff))
* **dc-validator:** display date/time/datetime cells as raw ISO, locale-independent ([9ca5719](https://git.datacontroller.io/dc/dc/commit/9ca57193b4aafa1e7bf216301cdf197cea6ec4c9))
* **editor:** await dynamic validation on paste; defer spinner ([ea03bde](https://git.datacontroller.io/dc/dc/commit/ea03bdecc5aaf917052add2c7e6fdc192b9974ce))
* **editor:** cancelEdit will reset cell's valid state ([51071b4](https://git.datacontroller.io/dc/dc/commit/51071b463bb311c6db48c301daa8cc7dfd92144a))
* **editor:** preserve commas in dropdown source values ([6af49cf](https://git.datacontroller.io/dc/dc/commit/6af49cf1fd8aa27cbf99f9514cca537339cdbf7e))
* **editor:** retry initSetup when the Handsontable instance isn't ready ([53b7ee0](https://git.datacontroller.io/dc/dc/commit/53b7ee0cb23329fa4f9d6211ffe6b102053fade9))
* **handsontable:** add exceljs ([38cb1e2](https://git.datacontroller.io/dc/dc/commit/38cb1e207b58822d71ef803e770a93bc7265bc1c))
* **handsontable:** horizontal scrollbar in dropdown ([ac0bd10](https://git.datacontroller.io/dc/dc/commit/ac0bd102127ced6b6a908a7244f35002f22cb669))
* **handsontable:** restore dark mode for v17 ([9d97bf7](https://git.datacontroller.io/dc/dc/commit/9d97bf7ea13199ab9dab10c5fc47e07b4751fb9a))
* **handsontable:** update licenseChecker exclude buffers - exceljs dep ([12c7d30](https://git.datacontroller.io/dc/dc/commit/12c7d30894b5c3709b683189da6e52255ad0a3e7))
* incrementing rk val ([7444209](https://git.datacontroller.io/dc/dc/commit/74442096bac1481a4992e5c97dac47c8d277cc3f))
* **licensing:** add protocol info ([3668a74](https://git.datacontroller.io/dc/dc/commit/3668a7426fec078ad7939b5bc815d7ad920c0d62)), closes [#178](https://git.datacontroller.io/dc/dc/issues/178)
* migrate handsontables to v17 ([1b73e35](https://git.datacontroller.io/dc/dc/commit/1b73e355b78a0039aa48f7a137095eb5470c08e2))
* **mocks:** return dynamic cell values as OBJ to match new contract ([a15fdc4](https://git.datacontroller.io/dc/dc/commit/a15fdc401e6cdcf934a5d599f8b7c4d197e9d2d4))
* removing display_value in SAS code ([9b18a45](https://git.datacontroller.io/dc/dc/commit/9b18a45d354ad617e590d62758ef761b01b3095a))
* rule_type ([adc6424](https://git.datacontroller.io/dc/dc/commit/adc6424211a83fb03b267bf8fa6b6d9fa72f185b))
* sending dynamic_values and dynamic_extended_values back as OBJ instead of ARR ([e859d33](https://git.datacontroller.io/dc/dc/commit/e859d3354e82f0faad53a0b8aa14ed66974cbffa))
* upcasing RAW_VALUE, bumping core, fixing tests ([982d507](https://git.datacontroller.io/dc/dc/commit/982d507ae3ad2ccc08e65678956199cd54b696f2))
* validate pasted values ([b661580](https://git.datacontroller.io/dc/dc/commit/b661580c604c2923b930911a1881aec7b06ecf23))
* viewLibs now fires only once, libPromise shared between the calls ([dc4e07a](https://git.datacontroller.io/dc/dc/commit/dc4e07a692a5e839986abb69b17e51024c4cdc03))
### Features
* **edit-record:** use native date/time/datetime pickers ([2d29346](https://git.datacontroller.io/dc/dc/commit/2d29346cbf09ad057743e6f5ebea12f3257f246e))
* **editor:** add READONLY, HIDDEN, ROUND and NUMBER_FORMAT validation rules ([4ea604f](https://git.datacontroller.io/dc/dc/commit/4ea604f9fbdd429108fe063b306ea376e756c107))
* **editor:** migrate date/time/datetime to HOT 17 Intl cell types ([f70ea2f](https://git.datacontroller.io/dc/dc/commit/f70ea2fe712ff9a42bf47387f9f0aaa6d1a1f511))
* **editor:** paste-validation overlay with cancel and confirm ([609731f](https://git.datacontroller.io/dc/dc/commit/609731ff99185a162614df13bde7afb1a2c3bbca))
* **editor:** SAS VA data-driven content embed mode (?embed=va) ([ffa3ff9](https://git.datacontroller.io/dc/dc/commit/ffa3ff9c103d6d80677f4f67e7d53418f201f491))
* **editor:** VA embed filter modes with pending UX ([ebe6972](https://git.datacontroller.io/dc/dc/commit/ebe6972dd7d38c5742ed7eb446e0448d98b2c8a7))
* **editor:** validate autofilled cells; fix paste validation lag ([11ee49a](https://git.datacontroller.io/dc/dc/commit/11ee49a57a0bc7ccd59e91d5fe37bb5db1bf7d69))
* **handsontable:** enable export plugin, add export menu items ([29aaa72](https://git.datacontroller.io/dc/dc/commit/29aaa72c6047bb309d325887ab602a9369a1ad74))
* migration script for adding new validation rules under [#241](https://git.datacontroller.io/dc/dc/issues/241) ([9f84a58](https://git.datacontroller.io/dc/dc/commit/9f84a580fb762da8c1c796a35eb4f531df1caf13))
* new validation rules in mpe_selectbox. [#241](https://git.datacontroller.io/dc/dc/issues/241) ([2f5d3df](https://git.datacontroller.io/dc/dc/commit/2f5d3dfc387f02353601aaa844aaeabde3e9f3e5))
### Reverts
* eager-load feature modules instead of lazy-loading ([3429a7a](https://git.datacontroller.io/dc/dc/commit/3429a7a2a6ecfa6115394e1c842061cc68bf2be3))
* **editor:** DISPLAY_VALUE change ([eb015d7](https://git.datacontroller.io/dc/dc/commit/eb015d712b01687f36ac92e38ab576a7c114b1ba))
## [7.8.2](https://git.datacontroller.io/dc/dc/compare/v7.8.1...v7.8.2) (2026-05-20)
-24
View File
@@ -16,21 +16,6 @@ Add `client/.npmrc` file with following content:
//pylon.sheetjs.com:54111/:_authToken="TOKEN-GOES-HERE"
```
In addition to the registry auth, the `@sheet/crypto` package is installed from a local tarball
(`client/libraries/sheet-crypto.tgz`) rather than the registry. This tarball is not committed to
the repo directly - only an encrypted copy is: `client/libraries/sheet-crypto.tgz.gpg`.
Before running `npm i`/`npm ci` in `client`, you must decrypt it using the passphrase
(stored as the `SHEET_PWD` CI secret - ask a repo maintainer if you don't have it):
```bash
cd client
echo "YOUR_PASSPHRASE" | gpg --batch --yes --passphrase-fd 0 \
--output ./libraries/sheet-crypto.tgz \
--decrypt ./libraries/sheet-crypto.tgz.gpg
npm i
```
[Handsontable](https://www.npmjs.com/package/handsontable)
Licence should be inserted in the `client/src/index.html` file:
@@ -81,15 +66,6 @@ To remove the release, you need to do it with repo administration over at [https
# Troubleshooting
## `npm i` fails with ENOENT on `sheet-crypto.tgz`
If `npm i`/`npm ci` in `client` fails with:
```
npm error enoent ENOENT: no such file or directory, open '.../client/libraries/sheet-crypto.tgz'
```
it means the encrypted `sheet-crypto.tgz.gpg` hasn't been decrypted yet. See
[Dependencies that requires licences](#dependencies-that-requires-licences) above for how to decrypt it.
## Makedata service "could not create directory" error
The dcpath folder should have its permissions set so that the system account (SYSUSERID) can both read and write to it.
-4
View File
@@ -1,4 +0,0 @@
# Auto-generated at build time (node ./src/version.ts) — gitignored, never linted.
# Formerly skipped via root .gitignore when prettier ran from the repo root;
# now that lint runs from client/, prettier reads this CWD-local ignore instead.
src/environments/version.ts
+1 -34
View File
@@ -68,14 +68,7 @@
}
],
"styles": ["src/styles.scss"],
"scripts": [
"node_modules/marked/marked.min.js",
{
"input": "src/assets/va-early.js",
"bundleName": "va-early",
"inject": true
}
],
"scripts": ["node_modules/marked/marked.min.js"],
"webWorkerTsConfig": "tsconfig.worker.json",
"main": "src/main.ts"
},
@@ -157,31 +150,5 @@
}
}
}
},
"schematics": {
"@schematics/angular:component": {
"type": "component"
},
"@schematics/angular:directive": {
"type": "directive"
},
"@schematics/angular:service": {
"type": "service"
},
"@schematics/angular:guard": {
"typeSeparator": "."
},
"@schematics/angular:interceptor": {
"typeSeparator": "."
},
"@schematics/angular:module": {
"typeSeparator": "."
},
"@schematics/angular:pipe": {
"typeSeparator": "."
},
"@schematics/angular:resolver": {
"typeSeparator": "."
}
}
}
@@ -1,132 +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 viewer-labels.cy.ts for the same
// pattern.
export {}
const username = Cypress.env('username')
const password = Cypress.env('password')
const hostUrl = Cypress.env('hosturl')
const appLocation = Cypress.env('appLocation')
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
const serverType = Cypress.env('serverType')
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
// Regression coverage for issue #253's vertical-array COLTYPE fix — see
// getdata.sas now sends one COLTYPE JSON-object string per cols[] row (via
// a LEFT JOIN keyed off the real dataset's columns), instead of one
// comma-joined sasparams.COLTYPE string covering every column. Two things
// are specifically at risk from that change and aren't covered elsewhere:
// 1. `_____DELETE__THIS__RECORD_____` (the delete checkbox column) is a
// client-side-only concept — %mp_getcols never has a row for it, so
// it can no longer travel via cols[].COLTYPE at all. Its Yes/No
// dropdown rule is now hardcoded client-side (deleteRecordColumnRule.ts)
// — test 1 proves that hardcode actually renders end to end.
// 2. Ordinary columns (e.g. SOME_DROPDOWN) still get their rule via the
// new per-row cols[].COLTYPE path — test 2 proves the migration didn't
// silently drop real columns' rules either.
// Column positions below come from COLHEADERS in
// sas/mocks/sasjs/services/editors/getdata.js:
// "_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,SOME_CHAR,SOME_DROPDOWN,..."
// — childNodes[0] on a body <tr> is the row-header <th>, so childNodes[N+1]
// is the Nth (0-indexed) column in that list.
context('coltype vertical-array regression tests: ', function () {
this.beforeAll(() => {
cy.visit(`${hostUrl}/SASLogon/logout`)
cy.loginAndUpdateValidKey(true)
})
this.beforeEach(() => {
cy.visit(hostUrl + appLocation)
visitPage('home')
})
it('1 | delete-record column renders its hardcoded Yes/No dropdown', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
clickOnEdit()
cy.get('.ht_master tbody tr', { timeout: longerCommandTimeout }).then(
(rows: any) => {
// childNodes[1] = _____DELETE__THIS__RECORD_____ (1st column)
cy.get(rows[1].childNodes[1])
.click({ force: true })
.then(($td) => {
cy.get('.htAutocompleteArrow', { withinSubject: $td })
.should('exist')
.click({ force: true })
cy.get('.autocompleteEditor .htCore tbody td').should(
($choices) => {
const texts = [...$choices].map((el) => el.innerText.trim())
expect(texts).to.deep.equal(['No', 'Yes'])
}
)
})
}
)
})
it('2 | an ordinary dropdown column (SOME_DROPDOWN) still gets its rule from cols[].COLTYPE', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
clickOnEdit()
cy.get('.ht_master tbody tr', { timeout: longerCommandTimeout }).then(
(rows: any) => {
// childNodes[4] = SOME_DROPDOWN (4th column, 0-indexed as 3). This
// only holds if DcValidator emits rules in VARNUM order — see the
// "orders rules by VARNUM" regression test in dc-validator.spec.ts.
cy.get(rows[1].childNodes[4])
.click({ force: true })
.then(($td) => {
cy.get('.htAutocompleteArrow', { withinSubject: $td }).should(
'exist'
)
})
}
)
})
})
const clickOnEdit = (callback?: any) => {
cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout })
.click()
.then(() => {
if (callback) callback()
})
}
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
cy.get('.app-loading', { timeout: longerCommandTimeout })
.should('not.exist')
.then(() => {
cy.get('.nav-tree clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
}).then((treeNodes: any) => {
let viyaLib
for (let node of treeNodes) {
if (node.innerText.toLowerCase().includes(libNameIncludes)) {
viyaLib = node
break
}
}
cy.get(viyaLib).within(() => {
cy.get('.clr-tree-node-content-container > button').click()
cy.get('.clr-treenode-link').then((innerNodes: any) => {
for (let innerNode of innerNodes) {
if (innerNode.innerText.toLowerCase().includes(tablename)) {
innerNode.click()
break
}
}
})
})
})
})
}
const visitPage = (url: string) => {
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
}
-220
View File
@@ -115,228 +115,8 @@ context('editor tests: ', function () {
})
})
})
// An invalid cell shouldn't block submission if its row
// is marked for delete
it('5 | Submits a delete-marked row with an invalid cell', (done) => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
cy.get('.ht_master tbody tr').then((rows: any) => {
// childNodes[5] = SOME_NUM (numeric) — feed it a non-numeric value
// to trigger HOT's native invalid mark.
cy.get(rows[1].childNodes[5])
.dblclick({ force: true })
.then(() => {
cy.focused()
.clear()
.type('not a number{enter}')
.then(() => {
// childNodes[1] = _____DELETE__THIS__RECORD_____
setDeleteFlag(rows[1].childNodes[1], 'Yes', () => {
submitTable(() => {
// Validation passed despite the invalid SOME_NUM cell:
// the confirm-submit modal's Submit button is enabled
// (validationDone === 1), not the "invalid values" abort.
cy.get('#submitBtn', {
timeout: longerCommandTimeout
})
.should('exist')
.should('not.be.disabled')
.then(() => done())
})
})
})
})
})
})
})
})
// The delete exemption must not affect the uniqueness (primary key) check.
it('6 | Still blocks a duplicate primary key even on a delete-marked row', (done) => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
cy.get('.ht_master tbody tr').then((rows: any) => {
cy.get(rows[0].childNodes[2]).then((firstPk) => {
const duplicateValue = firstPk.text()
// childNodes[2] = PRIMARY_KEY_FIELD
cy.get(rows[1].childNodes[2])
.dblclick({ force: true })
.then(() => {
cy.focused()
.clear()
.type(`${duplicateValue}{enter}`)
.then(() => {
setDeleteFlag(rows[1].childNodes[1], 'Yes', () => {
submitTable()
cy.get('.duplicate-keys-modal', {
timeout: longerCommandTimeout
})
.should('exist')
.then(() => done())
})
})
})
})
})
})
})
})
// REGEX_HARD_COL/REGEX_SOFT_COL only exist un-stripped on MPE_X_NEW (see
// RULE_DEMO_COLS in the getdata.js mock) — MPE_X_TEST stays clean to
// match the excel fixtures.
it('7 | Blocks submission of a value that fails HARDREGEX', (done) => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
scrollGridRight()
getCellByHeaderAndRow(1, 'REGEX_HARD_COL')
.dblclick({ force: true })
.then(() => {
cy.focused()
.clear()
.type('not-an-email{enter}')
.then(() => {
submitTable(() => {
cy.get('.modal-body').then((modalBody: any) => {
if (
modalBody[0].innerHTML
.toLowerCase()
.includes(`invalid values are present`)
) {
done()
}
})
})
})
})
})
})
})
it('8 | Warns (but still submits) a value that fails SOFTREGEX', (done) => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
scrollGridRight()
getCellByHeaderAndRow(1, 'REGEX_SOFT_COL')
.dblclick({ force: true })
.then(() => {
cy.focused()
.clear()
.type('not a postcode{enter}')
.then(() => {
getCellByHeaderAndRow(1, 'REGEX_SOFT_COL').should(
'have.class',
'dc-warning-cell'
)
submitTable(() => {
// Validation passed despite the SOFTREGEX warning: the
// confirm-submit modal's Submit button is enabled
// (validationDone === 1), not the "invalid values" abort.
cy.get('#submitBtn', { timeout: longerCommandTimeout })
.should('exist')
.should('not.be.disabled')
.then(() => done())
})
})
})
})
})
})
})
// Handsontable virtualizes columns — with 17 columns on MPE_X_NEW, only
// the ones in the current viewport actually exist in the DOM. Scroll all
// the way right so REGEX_HARD_COL/REGEX_SOFT_COL (the last two) render at
// all. Same technique already used in excel.cy.ts. Must be called (and
// re-settle) before any header/body query below, since scrolling replaces
// the previously-rendered column nodes.
const scrollGridRight = () => {
return cy
.get('#hotTable')
.find('div.ht_master.handsontable')
.find('div.wtHolder')
.scrollTo('right')
}
// Locates a body cell by its column's header text rather than a hardcoded
// childNodes index. Handsontable's hiddenColumns plugin (e.g. HIDDEN_COL,
// used by several demo columns ahead of REGEX_HARD_COL/REGEX_SOFT_COL in
// COLHEADERS) removes a hidden column's cells from BOTH the header row and
// every body row identically, so the header row's th index always lines up
// with the same-position childNode in any body row — this sidesteps having
// to know how many hidden columns precede the target. Re-queries both the
// header and body row fresh each call (rather than accepting a pre-fetched
// row) since virtualization/scrolling can replace either pane's nodes.
//
// The `.should()` retries until headerText actually shows up: Handsontable
// re-renders virtualized columns asynchronously after a scroll event, on
// its own schedule outside Cypress's command queue, so scrollTo() settling
// does not mean the target column has been rendered yet.
const getCellByHeaderAndRow = (rowIndex: number, headerText: string) => {
return cy
.get('.ht_clone_top .htCore thead tr th')
.should(($ths) => {
const texts = [...$ths].map((th) => th.innerText.trim())
expect(texts).to.include(headerText)
})
.then(($ths) => {
const index = [...$ths].findIndex(
(th) => th.innerText.trim() === headerText
)
return cy
.get('.ht_master tbody tr')
.then((rows: any) => rows[rowIndex].childNodes[index])
.then((cell) => cy.get(cell))
})
}
// Opens the _____DELETE__THIS__RECORD_____ dropdown editor on the given cell
// and picks the given choice ('Yes'/'No') — same technique as
// coltype-delete-record.cy.ts (arrow -> autocompleteEditor choice list).
const setDeleteFlag = (
deleteCell: any,
value: 'Yes' | 'No',
callback?: any
) => {
cy.get(deleteCell)
.click({ force: true })
.then(($td) => {
cy.get('.htAutocompleteArrow', { withinSubject: $td })
.should('exist')
.click({ force: true })
cy.get('.autocompleteEditor .htCore tbody td')
.contains(value)
.click({ force: true })
.then(() => {
if (callback) callback()
})
})
}
const clickOnEdit = (callback?: any) => {
cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout })
.click()
-130
View File
@@ -1,130 +0,0 @@
const hostUrl = Cypress.env('hosturl')
const appLocation = Cypress.env('appLocation')
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
const serverType = Cypress.env('serverType')
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
// SAS Visual Analytics data-driven content embed mode (`?embed=va`):
// - chrome is hidden (same as embed=true),
// - the editor opens in edit mode immediately (not read-only),
// - the only action button is a single Submit below the grid (stubbed in v1),
// - the top button bar and Add Record are hidden.
//
// The detailed PK+label data-merge logic is covered by the VaMessagingService
// unit spec; here we only assert the deterministic UI layout and that pushing a
// VA postMessage does not break the page.
context('embed=va (VA data-driven content) tests: ', function () {
this.beforeAll(() => {
cy.visit(`${hostUrl}/SASLogon/logout`)
cy.loginAndUpdateValidKey()
})
this.beforeEach(() => {
cy.visit(hostUrl + appLocation)
visitPage('home')
})
it('1 | opens the editor in VA mode: chrome hidden, editable, single bottom Submit', () => {
openEditorInVaMode('mpe_x_test', () => {
// Chrome hidden (same as embed=true)
cy.get('header.app-header').should('not.exist')
// Grid present and in edit mode (read-only Filter/Edit/Upload bar absent)
cy.get('#hotTable', { timeout: longerCommandTimeout }).should('exist')
cy.get('.btnCtrl button').contains('Edit').should('not.exist')
cy.get('.btnCtrl button').contains('Filter').should('not.exist')
// Top edit-mode buttons hidden in VA mode
cy.get('.btnCtrl button.btn-outline-danger').should('not.exist') // Cancel
cy.get('.btnCtrl').contains('Add Row').should('not.exist')
// Bottom Add Record hidden
cy.contains('button', 'Add Record').should('not.exist')
// Exactly one Submit button, below the grid, disabled in v1
cy.get('button').filter(':contains("Submit")').should('have.length', 1)
cy.get('button').contains('Submit').should('be.disabled')
})
})
it('2 | accepts a VA postMessage and stays in VA mode', () => {
// A VA message drives column visibility + filtering; an empty/unmatched
// message reloads the editor (unfiltered) while preserving ?embed=va.
openEditorInVaMode('mpe_x_test', () => {
cy.window().then((win) => {
win.postMessage(
{
version: '1',
resultName: 'dd1',
rowCount: 0,
availableRowCount: 0,
data: [],
columns: [],
parameters: []
},
'*'
)
})
// Editor still loads in VA mode after the message (chrome hidden, grid up)
cy.get('#hotTable', { timeout: longerCommandTimeout }).should('exist')
cy.get('header.app-header').should('not.exist')
cy.get('button').contains('Submit').should('exist')
})
})
})
// Opens a table from the tree (which routes to #/editor/LIB.TABLE), then
// re-visits the same route with ?embed=va so the app parses VA embed mode.
const openEditorInVaMode = (tablename: string, callback?: any) => {
openTableFromTree(libraryToOpenIncludes, tablename)
cy.get('#hotTable', { timeout: longerCommandTimeout })
.should('exist')
.then(() => {
cy.url().then((url) => {
const separator = url.includes('?') ? '&' : '?'
cy.visit(`${url}${separator}embed=va`)
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
'not.exist'
)
if (callback) callback()
})
})
}
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
cy.get('.app-loading', { timeout: longerCommandTimeout })
.should('not.exist')
.then(() => {
cy.get('.nav-tree clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
}).then((treeNodes: any) => {
let viyaLib
for (let node of treeNodes) {
if (node.innerText.toLowerCase().includes(libNameIncludes)) {
viyaLib = node
break
}
}
cy.get(viyaLib).within(() => {
cy.get('.clr-tree-node-content-container > button').click()
cy.get('.clr-treenode-link').then((innerNodes: any) => {
for (let innerNode of innerNodes) {
if (innerNode.innerText.toLowerCase().includes(tablename)) {
innerNode.click()
break
}
}
})
})
})
})
}
const visitPage = (url: string) => {
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
}
+5 -18
View File
@@ -386,19 +386,6 @@ context('excel tests: ', function () {
})
})
it('24 | Uploads Excel with leading whitespace in header row (should still succeed)', (done) => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
// Every header except the first has a leading space, matching a
// real-world defect from copy-pasting between spreadsheets/systems.
// Header matching must tolerate
// this rather than reporting the columns missing and aborting.
attachExcelFile('leading_whitespace_header_excel.xlsx', () => {
submitExcel()
rejectExcel(done)
})
})
// Large files break Cypress
// it ('? | Uploads Excel with size of 5MB', (done) => {
@@ -572,13 +559,13 @@ const checkResultOfXLSUpload = (callback?: any) => {
expect(cell.innerText).to.equal('▼\nOption 1')
cell = data[0].children[0].children[5]
expect(cell.innerText).to.equal('42')
// Cells render the raw stored ISO string verbatim (locale-independent;
// see registerIntlCellTypes). date -> YYYY-MM-DD, datetime ->
// YYYY-MM-DD HH:mm:ss, time -> HH:mm:ss.
// Cells render locale-formatted (Cypress pins --lang=en-GB); stored value
// stays raw ISO. date -> dd/mm/yyyy, datetime -> dd/mm/yyyy, HH:mm:ss,
// time -> HH:mm:ss (en-GB 24h, identical to the raw ISO time).
cell = data[0].children[0].children[6]
expect(cell.innerText).to.equal('1960-02-12')
expect(cell.innerText).to.equal('12/02/1960')
cell = data[0].children[0].children[7]
expect(cell.innerText).to.equal('1960-01-01 00:00:42')
expect(cell.innerText).to.equal('01/01/1960, 00:00:42')
cell = data[0].children[0].children[8]
expect(cell.innerText).to.equal('00:00:42')
-32
View File
@@ -36,38 +36,6 @@ context('liveness tests: ', function () {
})
})
// Multiple concurrent DC instances can cause the startupservice request
// to time out even though a retry succeeds. Fail only the first
// startupservice call (any later ones, e.g. from other tests/reloads,
// pass through untouched) and confirm the app still reaches the nav tree
// instead of showing the startup error modal.
it('2 | Recovers from a failed startupservice request via the single retry', () => {
let startupServiceCallCount = 0
cy.intercept('POST', '**/SASjsApi/stp/execute*', (req) => {
const isStartupService = JSON.stringify(req.body || '').includes(
'startupservice'
)
if (isStartupService) startupServiceCallCount++
if (isStartupService && startupServiceCallCount === 1) {
req.reply({ statusCode: 503, body: 'Service unavailable' })
} else {
req.continue()
}
}).as('stpExecute')
visitPage('home')
cy.get('.nav-tree clr-tree > clr-tree-node', {
// Longer than the retry's own 0-3s pause on top of the normal timeout.
timeout: longerCommandTimeout + 3000
}).should('exist')
cy.get('.abortMsg').should('not.exist')
})
/**
* Thist part will be needed if we add more tests in future
*/
+238 -232
View File
@@ -41,13 +41,16 @@ context('editor tests: ', function () {
cy.get('.viewbox-open').click()
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
cy.contains('.open-viewbox', viewbox_table, {
matchCase: false,
timeout: longerCommandTimeout
})
cy.get('.open-viewbox').then((viewboxNodes: any) => {
for (let viewboxNode of viewboxNodes) {
if (!viewboxNode.innerText.toLowerCase().includes(viewbox_table)) {
return
}
checkColumns(columns, () => {
done()
checkColumns(columns, () => {
done()
})
}
})
})
@@ -75,21 +78,48 @@ context('editor tests: ', function () {
libraryToOpenIncludes,
viewboxes.map((viewbox) => viewbox.viewbox_table)
)
viewboxes.forEach((viewbox) => {
cy.contains('.open-viewbox', viewbox.viewbox_table, {
matchCase: false,
timeout: longerCommandTimeout
})
})
cy.get('.open-viewbox').then((viewboxNodes: any) => {
let found = 0
let result = 0
for (let viewboxNode of viewboxNodes) {
for (let viewbox of viewboxes) {
if (
viewboxNode.innerText.toLowerCase().includes(viewbox.viewbox_table)
)
found++
}
}
viewboxes.forEach((viewbox) => {
checkColumns(viewbox.columns, () => {
result++
if (found < viewboxes.length) return
if (result === viewboxes.length) done()
})
cy.get('.viewboxes-container .viewbox', { withinSubject: null }).then(
(viewboxNodes: any) => {
for (let viewboxNode of viewboxNodes) {
cy.get(viewboxNode).within(() => {
cy.get('.table-title').then((tableTitle) => {
const title = tableTitle[0].innerText
const viewbox = viewboxes.find((vb) =>
title.toLowerCase().includes(vb.viewbox_table)
)
if (viewbox) {
cy.get('.ht_master.handsontable .htCore thead tr').then(
(viewboxColNodes: any) => {
let allColsHtml = viewboxColNodes[0].innerHTML
for (let col of viewbox?.columns) {
if (!allColsHtml.includes(col)) return
}
done()
}
)
}
})
})
}
}
)
})
})
@@ -103,19 +133,22 @@ context('editor tests: ', function () {
cy.get('.viewbox-open').click()
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
cy.contains('.open-viewbox', viewbox_table, {
matchCase: false,
timeout: longerCommandTimeout
})
cy.get('.open-viewbox').then((viewboxNodes: any) => {
for (let viewboxNode of viewboxNodes) {
if (!viewboxNode.innerText.toLowerCase().includes(viewbox_table)) {
return
}
openViewboxConfig(viewbox_table)
openViewboxConfig(viewbox_table)
removeAllColumns()
removeAllColumns()
addColumns(additionalColumns)
addColumns(additionalColumns)
checkColumns([...columns, ...additionalColumns], () => {
done()
checkColumns([...columns, ...additionalColumns], () => {
done()
})
}
})
})
@@ -129,32 +162,35 @@ context('editor tests: ', function () {
cy.get('.viewbox-open').click()
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
cy.contains('.open-viewbox', viewbox_table, {
matchCase: false,
timeout: longerCommandTimeout
})
cy.get('.open-viewbox').then((viewboxNodes: any) => {
for (let viewboxNode of viewboxNodes) {
if (!viewboxNode.innerText.toLowerCase().includes(viewbox_table)) {
return
}
openViewboxConfig(viewbox_table)
openViewboxConfig(viewbox_table)
removeAllColumns()
removeAllColumns()
addColumns(additionalColumns, () => {
cy.wait(1000)
//reorder
cy.get('.col-box.column-MOVE_TYPE')
.realMouseDown({ button: 'left', position: 'center' })
.realMouseMove(0, 10, { position: 'center' })
cy.wait(200) // In our case, we wait 200ms cause we have animations which we are sure that take this amount of time
cy.get('.col-box.column-IS_PK')
.realMouseMove(0, 0, { position: 'center' })
.realMouseUp()
//reorder end
addColumns(additionalColumns, () => {
cy.wait(1000)
//reorder
cy.get('.col-box.column-MOVE_TYPE')
.realMouseDown({ button: 'left', position: 'center' })
.realMouseMove(0, 10, { position: 'center' })
cy.wait(200) // In our case, we wait 200ms cause we have animations which we are sure that take this amount of time
cy.get('.col-box.column-IS_PK')
.realMouseMove(0, 0, { position: 'center' })
.realMouseUp()
//reorder end
cy.wait(500)
cy.wait(500)
checkColumns([...columns, ...additionalColumns.reverse()], () => {
done()
})
checkColumns([...columns, ...additionalColumns.reverse()], () => {
done()
})
})
}
})
})
@@ -168,47 +204,53 @@ context('editor tests: ', function () {
cy.get('.viewbox-open').click()
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
cy.contains('.open-viewbox', viewbox_table, {
matchCase: false,
timeout: longerCommandTimeout
})
cy.get('.open-viewbox').then((viewboxNodes: any) => {
for (let viewboxNode of viewboxNodes) {
if (!viewboxNode.innerText.toLowerCase().includes(viewbox_table)) {
return
}
openViewboxConfig(viewbox_table)
viewboxNode.click()
removeAllColumns()
removeAllColumns()
addColumns(additionalColumns, () => {
cy.wait(1000)
//reorder
cy.get('.col-box.column-MOVE_TYPE')
.realMouseDown({ button: 'left', position: 'center' })
.realMouseMove(0, 10, { position: 'center' })
cy.wait(200) // In our case, we wait 200ms cause we have animations which we are sure that take this amount of time
cy.get('.col-box.column-IS_PK')
.realMouseMove(0, 0, { position: 'center' })
.realMouseUp()
//reorder end
addColumns(additionalColumns, () => {
cy.wait(1000)
//reorder
cy.get('.col-box.column-MOVE_TYPE')
.realMouseDown({ button: 'left', position: 'center' })
.realMouseMove(0, 10, { position: 'center' })
cy.wait(200) // In our case, we wait 200ms cause we have animations which we are sure that take this amount of time
cy.get('.col-box.column-IS_PK')
.realMouseMove(0, 0, { position: 'center' })
.realMouseUp()
//reorder end
cy.wait(500)
cy.wait(500)
checkColumns([...columns, ...additionalColumns.reverse()], () => {
const colToRemove = 'MOVE_TYPE'
checkColumns([...columns, ...additionalColumns.reverse()], () => {
const colToRemove = 'MOVE_TYPE'
removeColumn(colToRemove)
checkColumns(
[
...columns,
...additionalColumns.filter((col) => col !== colToRemove)
],
() => {
addColumns([colToRemove], () => {
checkColumns([...columns, ...additionalColumns.reverse()], () => {
done()
})
})
}
)
})
removeColumn(colToRemove)
checkColumns(
[
...columns,
...additionalColumns.filter((col) => col !== colToRemove)
],
() => {
addColumns([colToRemove], () => {
checkColumns(
[...columns, ...additionalColumns.reverse()],
() => {
done()
}
)
})
}
)
})
})
}
})
})
@@ -291,68 +333,68 @@ context('editor tests: ', function () {
})
})
// The base VIEW table and a viewbox on a different table must never
// share a filter dialog's cached state - each keeps its own entry in
// the globals filter cache (getFilterObjPath), keyed by viewboxId where
// one is present. Opened from the VIEW page specifically
// (rootParam === 'view') - the one branch where this previously broke,
// since a 'view'-page QueryComponent was always treated as the base
// table regardless of viewboxId.
it('7 | Base table filter and viewbox filter stay isolated from each other', () => {
const viewbox_table = 'mpe_audit'
// We will enable this test when we figure out how to mock filtering
// it('7 | Add viewboxes and filter', () => {
// const viewboxes = ['mpe_x_test', 'mpe_validations']
visitPage('view/data')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
// openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
// Filter the base table.
openViewFilter()
setFilterWithValue('SOME_CHAR', 'this is dummy data', 'value', () => {
cy.get('app-query', { withinSubject: null }).should('not.exist')
// cy.get('.viewbox-open').click()
// openTableFromViewboxTree(libraryToOpenIncludes, viewboxes)
// Open a viewbox on a *different* table and check its filter dialog
// does not show the base table's filter.
openViewViewboxes()
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
closeViewboxModal()
// cy.wait(1000)
// cy.contains retries until the viewbox's title actually shows the
// expected table name (it can render before its data/title loads),
// then .parents() walks back up to its container - avoids a
// one-shot .then() racing the title's own async update, which is
// what made this flaky in the first place.
cy.contains('.viewboxes-container .viewbox .table-title', viewbox_table, {
matchCase: false,
timeout: longerCommandTimeout
})
.parents('.viewbox')
.first()
.within(() => {
cy.get('clr-icon[shape="filter"]').click()
// closeViewboxModal()
cy.get('.filter-modal code.language-sql', {
withinSubject: null
}).should(($code) => {
expect($code.text()).to.not.contain('SOME_CHAR')
})
// cy.get('.viewboxes-container .viewbox', { withinSubject: null }).then(
// (viewboxNodes: any) => {
// for (let viewboxNode of viewboxNodes) {
// cy.get(viewboxNode).within(() => {
// cy.get('.table-title').then((title: any) => {
// cy.get('.hot-spinner')
// .should('not.exist')
// .then(() => {
// cy.get('clr-icon[shape="filter"]').then((filterButton) => {
// filterButton[0].click()
// })
// Reverse direction: filter the viewbox, then confirm
// re-opening the base table's own filter dialog still shows
// the base table's filter, not the viewbox's.
setFilterWithValue('LIBREF', 'sasdemo', 'value', () => {
cy.get('app-query', { withinSubject: null }).should('not.exist')
// if (title[0].innerText.includes('MPE_X_TEST')) {
// setFilterWithValue(
// 'SOME_CHAR',
// 'this is dummy data',
// 'value',
// () => {
// cy.get('app-query', { withinSubject: null })
// .should('not.exist')
// .get('.ht_master.handsontable tbody tr')
// .then((rowNodes) => {
// const tr = rowNodes[0]
openViewFilter()
// expect(rowNodes).to.have.length(1)
// expect(tr.innerText).to.equal('0')
// })
// }
// )
// } else if (title[0].innerText.includes('MPE_VALIDATIONS')) {
// setFilterWithValue('BASE_COL', 'ALERT_LIB', 'value', () => {
// cy.get('app-query', { withinSubject: null })
// .should('not.exist')
// .get('.ht_master.handsontable tbody tr')
// .then((rowNodes) => {
// const tr = rowNodes[0]
cy.get('.filter-modal code.language-sql', {
withinSubject: null
}).should(($code) => {
expect($code.text()).to.contain('SOME_CHAR')
expect($code.text()).to.not.contain('LIBREF')
})
})
})
})
})
// expect(rowNodes).to.have.length(1)
// expect(tr.innerText).to.contain('ALERT_LIB')
// })
// })
// }
// })
// })
// })
// }
// }
// )
// })
})
const removeAllColumns = () => {
@@ -365,40 +407,32 @@ const removeAllColumns = () => {
)
}
// Asserts that at least one open viewbox currently shows exactly this
// column list, in order. Uses a single .should() callback (synchronous
// jQuery reads only, no nested cy.get()/.then()) so Cypress retries the
// whole check against a fresh DOM snapshot until it passes or times out -
// unlike .then(), which only ever sees one snapshot and, combined with an
// early return on a mismatch, would silently never invoke the caller's
// callback while Handsontable/the viewbox's data are still loading or
// re-rendering asynchronously.
const checkColumns = (columns: string[], callback: () => void) => {
cy.get('.viewboxes-container .viewbox', { withinSubject: null }).should(
($viewboxNodes) => {
const matches = $viewboxNodes.toArray().some((viewboxNode) => {
// rowHeaders: true (viewboxes.component.ts) adds a leading, unlabeled
// corner <th> before the actual data columns - skip it, or every
// comparison is off by one against the row-header cell instead of
// the first real column.
const headerCells = Cypress.$(viewboxNode)
.find('.ht_master.handsontable thead tr th')
.toArray()
.slice(1)
cy.get('.viewboxes-container .viewbox', { withinSubject: null }).then(
(viewboxNodes: any) => {
for (let viewboxNode of viewboxNodes) {
cy.get(viewboxNode).within(() => {
cy.get('.ht_master.handsontable thead tr th').then(
(viewboxColNodes: any) => {
console.log('viewboxColNode', viewboxColNodes)
console.log('columns', columns)
for (let i = 0; i < viewboxColNodes.length; i++) {
const col = columns[i] || ''
const colNode = viewboxColNodes[i]
return columns.every((col, i) =>
(headerCells[i]?.innerHTML || '')
.toLowerCase()
.includes(col.toLowerCase())
)
})
if (
!colNode.innerHTML.toLowerCase().includes(col.toLowerCase())
)
return
}
expect(matches, `a viewbox showing columns: ${columns.join(', ')}`).to.be
.true
callback()
}
)
})
}
}
)
cy.then(() => callback())
}
const closeViewboxModal = () => {
@@ -410,7 +444,9 @@ const removeColumn = (column: string) => {
}
const addColumns = (columns: string[], callback?: () => void) => {
for (const column of columns) {
for (let i = 0; i < columns.length; i++) {
const column = columns[i]
cy.get('.cols-search input', { withinSubject: null }).type(column)
cy.get('.cols-search .autocomplete-wrapper', { withinSubject: null })
.first()
@@ -418,81 +454,51 @@ const addColumns = (columns: string[], callback?: () => void) => {
cy.get('.cols-search .autocomplete-wrapper', { withinSubject: null })
.first()
.trigger('keydown', { key: 'Enter' })
// Wait for the actual column box to render before moving on to the
// next column (or firing the callback) - selecting it doesn't add it
// to the DOM synchronously, and cy.get()'s own retry covers that gap
// instead of relying on a fixed cy.wait().
cy.get(`.col-box.column-${column}`, { timeout: longerCommandTimeout })
.then(() => {
if (i === columns.length - 1 && callback) callback()
})
}
if (callback) cy.then(() => callback())
}
// The VIEW page's "options" dropdown (viewer.component.html) - distinct
// from the editor page's dedicated .viewbox-open button used by the
// tests above, since test 7 runs on the VIEW page specifically.
const openViewOptionsDropdown = () => {
cy.get('.filterSide', { withinSubject: null }).click()
}
const openViewFilter = () => {
openViewOptionsDropdown()
cy.get('[clrDropdownItem]', { withinSubject: null })
.contains('Filter')
.click()
}
const openViewViewboxes = () => {
openViewOptionsDropdown()
cy.get('[clrDropdownItem]', { withinSubject: null })
.contains('Viewboxes')
.click()
}
// cy.contains retries until a matching node appears/updates - the open
// viewbox's own list entry can render before its table-name text has
// actually loaded, so a one-shot .then() lookup here would race it.
const openViewboxConfig = (viewbox_tablename: string) => {
cy.contains('.open-viewbox', viewbox_tablename, {
matchCase: false,
timeout: longerCommandTimeout
}).click()
cy.get('.open-viewbox').then((viewboxes: any) => {
for (let openViewbox of viewboxes) {
if (openViewbox.innerText.toLowerCase().includes(viewbox_tablename))
openViewbox.click()
}
})
}
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
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
// Small settle wait: right after a fresh visit (an extra navigation on
// top of beforeEach's own, e.g. test 7's visitPage('view/data')), the
// tree component can still be re-rendering, causing a node to be found
// then swapped out mid-click - same fix already proven in
// viewer-labels.cy.ts.
cy.wait(300)
for (let node of treeNodes) {
if (new RegExp(libNameIncludes).test(node.innerText.toLowerCase())) {
viyaLib = node
break
}
}
const libraryNodeSelector = '.nav-tree clr-tree > clr-tree-node'
const libraryMatcher = new RegExp(libNameIncludes, 'i')
cy.get(viyaLib).within(() => {
cy.get('.clr-tree-node-content-container p').click()
cy.contains(libraryNodeSelector, libraryMatcher, {
timeout: longerCommandTimeout
})
.find('.clr-tree-node-content-container p')
.click()
// Re-query the library node fresh rather than reusing the reference
// from before the click above - expanding a library can replace its
// whole DOM subtree once its child tables load (that's what made the
// earlier cached-reference version of this helper flaky: Cypress
// detected the click's own side effect detaching the element it was
// still verifying actionability against).
cy.contains(libraryNodeSelector, libraryMatcher, {
timeout: longerCommandTimeout
}).within(() => {
cy.contains('.clr-treenode-link', tablename, {
matchCase: false,
timeout: longerCommandTimeout
}).click()
})
cy.get('.clr-treenode-link').then((innerNodes: any) => {
for (let innerNode of innerNodes) {
if (innerNode.innerText.toLowerCase().includes(tablename)) {
innerNode.click()
break
}
}
})
})
})
})
}
const setFilterWithValue = (
-243
View File
@@ -1,243 +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. download.cy.ts, which gets
// this for free via a real import.
export {}
const username = Cypress.env('username')
const password = Cypress.env('password')
const hostUrl = Cypress.env('hosturl')
const appLocation = Cypress.env('appLocation')
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
const serverType = Cypress.env('serverType')
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
// Viewer column-label display toggle (`?labels=true`): clients surfacing DC in
// SAS Visual Analytics want column LABELs shown instead of NAMEs. Default
// behavior (no param) must stay unchanged; the right-click context menu's
// "Show labels"/"Show names" item is the in-app affordance, and it drives the
// same URL param so state is shareable/refreshable. See mock data in
// sas/mocks/sasjs/services/public/viewdata.js: MPE_X_TEST's SOME_CHAR/SOME_DATE
// have LABELs that differ from NAME, and SOME_NUM has a blank LABEL (fallback
// to NAME) — this spec exercises exactly that fixture.
context('viewer column labels toggle tests: ', function () {
this.beforeAll(() => {
cy.visit(`${hostUrl}/SASLogon/logout`)
// forceLicenceKey=true: without it, loginAndUpdateValidKey() only
// generates key data and logs back out if no valid license already
// exists — it doesn't actually apply one (see commands.ts). Several
// specs call it with no argument and rely on an *earlier* spec in the
// same CI run having already forced a valid license onto the shared
// server first (csv.cy.ts, excel.cy.ts, etc.). This spec shouldn't
// depend on run order, so force it explicitly.
cy.loginAndUpdateValidKey(true)
})
this.beforeEach(() => {
// No re-login here: beforeAll's loginAndUpdateValidKey() already
// authenticated the session, and it persists across tests in this file
// (see filtering.cy.ts/csv.cy.ts for the same pattern). Re-typing into
// the login form would fail — it's hidden once already logged in.
cy.visit(hostUrl + appLocation)
// Visit 'home' first, matching filtering.cy.ts/csv.cy.ts/download.cy.ts —
// going straight to 'view/data' skips whatever startup-data fetch 'home'
// triggers, which passed locally (fast round-trips) but left the
// nav-tree empty in CI's slower environment.
visitPage('home')
visitPage('view/data')
})
it('1 | default (no param): headers show NAMEs', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
cy.get('#hotTable .ht_clone_top .htCore thead tr', {
timeout: longerCommandTimeout
}).should(($headerRow) => {
expect($headerRow[0].innerHTML).to.include('SOME_CHAR')
expect($headerRow[0].innerHTML).to.not.include('Some Character Column')
})
})
it('2 | ?labels=true: headers show LABELs, blank LABEL falls back to NAME', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
appendUrlParam('labels=true')
cy.get('#hotTable .ht_clone_top .htCore thead tr', {
timeout: longerCommandTimeout
}).should(($headerRow) => {
const html = $headerRow[0].innerHTML
expect(html).to.include('Some Character Column')
expect(html).to.include('Some Date')
// SOME_NUM has a blank LABEL in the mock -> falls back to NAME
expect(html).to.include('SOME_NUM')
})
})
it('3 | context menu "Show labels"/"Show names" toggles headers and the URL', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
toggleColumnLabelsFromContextMenu('Show labels')
cy.url().should('include', 'labels=true')
cy.get('#hotTable .ht_clone_top .htCore thead tr', {
timeout: longerCommandTimeout
}).should(($headerRow) => {
expect($headerRow[0].innerHTML).to.include('Some Character Column')
})
toggleColumnLabelsFromContextMenu('Show names')
cy.url().should('not.include', 'labels=true')
cy.get('#hotTable .ht_clone_top .htCore thead tr', {
timeout: longerCommandTimeout
}).should(($headerRow) => {
expect($headerRow[0].innerHTML).to.include('SOME_CHAR')
})
})
it('4 | ?embed=va&labels=true: labels shown with embed chrome hidden', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
appendUrlParam('embed=va&labels=true')
cy.get('header.app-header').should('not.exist')
cy.get('#hotTable .ht_clone_top .htCore thead tr', {
timeout: longerCommandTimeout
}).should(($headerRow) => {
expect($headerRow[0].innerHTML).to.include('Some Character Column')
})
})
it('5 | info dropdown: first line is NAME (in both toggle states)', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
// No assertion on the item's label text here: `info` has a custom
// `renderer`, so Handsontable never renders its `name` ("test info") as
// text at all — the renderer's own output (checked below) is the only
// real content.
openColumnDropdown('SOME_CHAR')
cy.get('.htDropdownMenu').should(($menu) => {
expect($menu.text()).to.match(/NAME: SOME_CHAR/)
})
cy.get('body').click(0, 0) // close menu
appendUrlParam('labels=true')
openColumnDropdown('Some Character Column')
cy.get('.htDropdownMenu').should(($menu) => {
// NAME is always shown first, even while headers display as LABEL
expect($menu.text()).to.match(/NAME: SOME_CHAR/)
})
})
})
const visitPage = (url: string) => {
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
}
// Re-visits the current route with extra hash-query params appended, then
// forces a real reload. Under hash routing (`useHash: true`), changing only
// the URL's hash fragment is a same-document navigation — Angular Router
// still reacts to it (which is enough for `useLabels`, read reactively from
// `route.queryParams`), but `embed` is parsed once from `window.location.hash`
// at app bootstrap (app.component.ts), so `?embed=va` only takes effect after
// an actual reload.
const appendUrlParam = (param: string) => {
cy.url().then((url) => {
const separator = url.includes('?') ? '&' : '?'
cy.visit(`${url}${separator}${param}`)
cy.reload()
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
'not.exist'
)
})
}
const openColumnDropdown = (headerText: string) => {
// Handsontable renders the sticky/frozen header via a separate clone pane
// (.ht_clone_top) for scroll behavior; the header row inside the main
// .ht_master pane is kept visibility:hidden (replaced visually by the
// clone). .ht_clone_top is the real, interactive one — confirmed via
// `document.querySelector('#hotTable .ht_clone_top thead').innerText` in
// a live browser session.
cy.get('#hotTable .ht_clone_top .htCore thead button.changeType', {
timeout: longerCommandTimeout
})
.parents('th')
.filter((_, th) => Cypress.$(th).text().includes(headerText))
.last()
.as('targetHeader')
// Click the header text first to select the column — the `info` item's
// renderer reads hot.getSelected() to decide which column to describe,
// so without an active selection it always shows "No info found".
cy.get('@targetHeader').click()
cy.get('@targetHeader').find('button.changeType').click({ force: true })
}
const toggleColumnLabelsFromContextMenu = (
menuItemText: 'Show labels' | 'Show names'
) => {
cy.get('#hotTable .ht_master.handsontable .htCore tbody tr td', {
timeout: longerCommandTimeout
})
.first()
// force: true — the first body row can sit under an overlay clone's
// header/sort-icon layer, which fails Cypress's actionability check
// even though the cell is the real rightclick target underneath.
.rightclick({ force: true })
cy.get('.htContextMenu').contains(menuItemText).click()
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
}
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
cy.get('.app-loading', { timeout: longerCommandTimeout })
.should('not.exist')
.then(() => {
cy.get('.nav-tree clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
}).then((treeNodes: any) => {
let viyaLib
for (let node of treeNodes) {
if (node.innerText.toLowerCase().includes(libNameIncludes)) {
viyaLib = node
break
}
}
cy.get(viyaLib).within(() => {
// Small settle wait: right after a fresh visit/reload, the tree
// component can still be re-rendering, causing this node to be
// found then swapped out mid-click ("disappeared from the page").
cy.wait(300)
cy.get(
'.clr-tree-node-content-container .clr-treenode-content p'
).click()
cy.get('.clr-treenode-link').then((innerNodes: any) => {
for (let innerNode of innerNodes) {
if (innerNode.innerText.toLowerCase().includes(tablename)) {
innerNode.click()
break
}
}
})
})
})
})
// Selecting the table triggers async SPA routing + a viewdata fetch;
// wait for the grid to actually render before any subsequent action
// (reading the URL, right-clicking, opening a header dropdown) — without
// this, callers can act while still on the intermediate library-only
// route or a not-yet-rendered grid. Waiting specifically for a header's
// dropdown button (not just the <tr> shell) confirms Handsontable has
// finished populating header cell contents, not just their DOM rows.
cy.get('#hotTable .ht_clone_top .htCore thead button.changeType', {
timeout: longerCommandTimeout
}).should('exist')
}
+91 -145
View File
@@ -23,33 +23,21 @@
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
import 'cypress-file-upload'
import 'cypress-file-upload';
import { arrayBufferToBase64 } from './../util/helper-functions'
import moment from 'moment'
import * as moment from 'moment'
// These custom commands were added via Cypress.Commands.add() below but never
// had a type augmentation, so every spec calling cy.loginAndUpdateValidKey()/
// cy.isLoggedIn() had an unresolved-property error.
declare global {
namespace Cypress {
interface Chainable {
isLoggedIn(callback: (exist: boolean) => void): Chainable<void>
loginAndUpdateValidKey(forceLicenceKey?: boolean): Chainable<void>
}
}
}
const username = Cypress.env('username')
const password = Cypress.env('password')
const hostUrl = Cypress.env('hosturl')
const appLocation = Cypress.env('appLocation')
const username = Cypress.env('username');
const password = Cypress.env('password');
const hostUrl = Cypress.env('hosturl');
const appLocation = Cypress.env('appLocation');
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
const site_id_SASJS = Cypress.env('site_id_SASJS')
Cypress.Commands.add('isLoggedIn', (callback: (exist: boolean) => void) => {
cy.get('body').then(($body) => {
if ($body.find('.nav-tree').length > 0) {
cy.get('body').then($body => {
if ($body.find(".nav-tree").length > 0) {
if (callback) callback(true)
} else {
if (callback) callback(false)
@@ -57,92 +45,76 @@ Cypress.Commands.add('isLoggedIn', (callback: (exist: boolean) => void) => {
})
})
Cypress.Commands.add(
'loginAndUpdateValidKey',
(forceLicenceKey: boolean = false) => {
cy.visit(hostUrl + appLocation)
Cypress.Commands.add('loginAndUpdateValidKey', (forceLicenceKey: boolean = false) => {
cy.visit(hostUrl + appLocation);
cy.wait(2000)
cy.wait(2000)
cy.get('body').then(($body) => {
const usernameInput = $body.find('input.username')[0]
cy.get('body').then($body =>{
const usernameInput = $body.find("input.username")[0]
if (usernameInput && !Cypress.dom.isHidden(usernameInput)) {
cy.get('input.username').type(username)
cy.get('input.password').type(password)
if (usernameInput && !Cypress.dom.isHidden(usernameInput)) {
cy.get('input.username').type(username);
cy.get('input.password').type(password);
cy.get('.login-group button').click()
}
cy.get('.login-group button').click()
}
cy.get('.app-loading', { timeout: longerCommandTimeout })
.should('not.exist')
.then(() => {
cy.wait(2000)
cy.get('.app-loading', {timeout: longerCommandTimeout}).should('not.exist').then(() => {
cy.wait(2000)
if ($body.find('.nav-tree').length > 0) {
/**
* If licence key is already working, then skip rest of the function
*/
return logout(() => {
return
})
} else {
const keyData = {
valid_until: moment().add(20, 'day').format('YYYY-MM-DD'),
number_of_users: 10,
hot_license_key: '',
site_id: '',
demo: false
if ($body.find(".nav-tree").length > 0) {
/**
* If licence key is already working, then skip rest of the function
*/
return logout(() => {
return
})
} else{
const keyData = {
valid_until: moment().add(20, 'day').format('YYYY-MM-DD'),
number_of_users: 10,
hot_license_key: '',
site_id: '',
demo: false
}
return generateKeys(keyData.valid_until, keyData.number_of_users, keyData.hot_license_key, keyData.demo, (keysGen: any) => {
return acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
return generateKeys(
keyData.valid_until,
keyData.number_of_users,
keyData.hot_license_key,
keyData.demo,
(keysGen: any) => {
if (!forceLicenceKey) {
return logout(() => {
return
})
} else {
return updateLicenseKeyQuick(keysGen, () => {
cy.wait(25000)
return acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
if (!forceLicenceKey) {
return logout(() => {
return
})
} else {
return updateLicenseKeyQuick(keysGen, () => {
cy.wait(25000)
return acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
return logout(() => {
return
})
})
})
}
return logout(() => {
return
})
})
}
)
}
})
}
})
})
}
})
}
)
});
});
const logout = (callback?: any) => {
cy.get('.header-actions .dropdown-toggle')
.click()
.then(() => {
cy.get('.header-actions .dropdown-menu > .separator')
.next()
.click()
.then(() => {
if (callback) callback()
})
cy.get('.header-actions .dropdown-toggle').click().then(() => {
cy.get('.header-actions .dropdown-menu > .separator').next().click().then(() => {
if (callback) callback()
})
})
}
const updateLicenseKeyQuick = (keys: any, callback: any) => {
@@ -160,15 +132,11 @@ const updateLicenseKeyQuick = (keys: any, callback: any) => {
const acceptTermsIfPresented = (callback?: any) => {
cy.url().then((url: string) => {
if (url.includes('licensing/register')) {
cy.get('.card-block')
.scrollTo('bottom')
.then(() => {
cy.get('#checkbox1')
.click()
.then(() => {
if (callback) callback(true)
})
cy.get('.card-block').scrollTo('bottom').then(() => {
cy.get('#checkbox1').click().then(() => {
if (callback) callback(true)
})
})
} else {
if (callback) callback(false)
}
@@ -183,36 +151,23 @@ const isLicensingPage = (callback: any) => {
const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => {
cy.get('button').contains('Paste licence').click()
cy.get('.license-key-form textarea', { timeout: longerCommandTimeout })
.invoke('val', licenseKey)
.trigger('input')
.should('not.be.undefined')
cy.get('.activation-key-form textarea', { timeout: longerCommandTimeout })
.invoke('val', activationKey)
.trigger('input')
.should('not.be.undefined')
cy.get('.license-key-form textarea', {timeout: longerCommandTimeout}).invoke('val', licenseKey).trigger('input').should('not.be.undefined')
cy.get('.activation-key-form textarea', {timeout: longerCommandTimeout}).invoke('val', activationKey).trigger('input').should('not.be.undefined')
cy.get('button.apply-keys').click()
}
const visitPage = (url: string) => {
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
cy.visit(`${hostUrl}${appLocation}/#/${url}`);
}
const generateKeys = async (
valid_until: string,
users_allowed: number,
hot_license_key: string,
demo: boolean,
resultCallback?: any
) => {
let keyPair = await window.crypto.subtle.generateKey(
{
name: 'RSA-OAEP',
modulusLength: 2024,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-256'
const generateKeys = async (valid_until: string, users_allowed: number, hot_license_key: string, demo: boolean, resultCallback?: any) => {
let keyPair = await window.crypto.subtle.generateKey({
name: "RSA-OAEP",
modulusLength: 2024,
publicExponent: new Uint8Array([1, 0, 1]),
hash: "SHA-256"
},
true,
['encrypt', 'decrypt']
["encrypt", "decrypt"]
)
let licenseData = {
@@ -229,39 +184,30 @@ const generateKeys = async (
console.log(encoded)
let cipher = await window.crypto.subtle
.encrypt(
{
name: 'RSA-OAEP'
},
keyPair.publicKey,
encoded
)
.then(
(value) => {
return value
},
(err) => {
console.log('Encrpyt error', err)
}
)
let cipher = await window.crypto.subtle.encrypt(
{
name: "RSA-OAEP"
},
keyPair.publicKey,
encoded
).then((value) => {
return value
}, (err) => {
console.log('Encrpyt error', err)
})
if (!cipher) {
alert('Encryptin keys failed')
throw new Error('Encryptin keys failed')
}
let privateKeyBytes = await window.crypto.subtle.exportKey(
'pkcs8',
keyPair.privateKey
)
let privateKeyBytes = await window.crypto.subtle.exportKey('pkcs8', keyPair.privateKey)
let activationKey = await arrayBufferToBase64(privateKeyBytes)
let licenseKey = await arrayBufferToBase64(cipher)
if (resultCallback)
resultCallback({
activationKey,
licenseKey
})
}
if (resultCallback) resultCallback({
activationKey,
licenseKey
})
}
Binary file not shown.
+1 -1
View File
@@ -10,7 +10,7 @@ const check = (cwd) => {
onlyAllow:
'AFLv2.1;Apache 2.0;Apache-2.0;Apache*;Artistic-2.0;0BSD;BSD*;BSD-2-Clause;BSD-3-Clause;CC0-1.0;CC-BY-3.0;CC-BY-4.0;ISC;MIT;MPL-2.0;ODC-By-1.0;Python-2.0;Unlicense;',
excludePackages:
'@cds/city@1.1.0;@handsontable/angular-wrapper@16.0.1;@handsontable/angular-wrapper@17.1.0;@handsontable/angular-wrapper@18.0.0;handsontable@^16.0.1;handsontable@16.2.0;handsontable@17.1.0;handsontable@18.0.0;hyperformula@2.7.1;hyperformula@3.0.0;hyperformula@3.1.0;hyperformula@3.2.0;hyperformula@3.3.0;jackspeak@3.4.3;path-scurry@1.11.1;package-json-from-dist@1.0.1;buffers@0.1.1'
'@cds/city@1.1.0;@handsontable/angular-wrapper@16.0.1;@handsontable/angular-wrapper@17.1.0;handsontable@^16.0.1;handsontable@16.2.0;handsontable@17.1.0;hyperformula@2.7.1;hyperformula@3.0.0;hyperformula@3.1.0;hyperformula@3.2.0;hyperformula@3.3.0;jackspeak@3.4.3;path-scurry@1.11.1;package-json-from-dist@1.0.1;buffers@0.1.1'
},
(error, json) => {
if (error) {
+6241 -5941
View File
File diff suppressed because it is too large Load Diff
+25 -35
View File
@@ -17,11 +17,7 @@
"deploy_viya": "rsync -avhe ssh ./dist/* --delete ${npm_config_account}@sas.4gl.io:/var/www/html/${npm_config_account}/dc/dev",
"deploy_sasjs": "rsync -avhe ssh ./dist/* --delete root@${npm_config_account}.4gl.io:/var/www/html/dc/dev",
"viyabuild": "cd build; ./viyabuild.sh",
"lint": "npm run lint:fix",
"lint:fix": "npx prettier --write \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
"lint:fix:silent": "npx prettier --log-level silent --write \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
"lint:check": "npx prettier --check \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
"lint:check:silent": "npx prettier --log-level silent --check \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
"lint": "cd .. && npm run lint",
"test": "npx ng test",
"test:headless": "npx ng test --no-watch --no-progress --browsers ChromeHeadlessCI",
"watch": "ng test watch=true",
@@ -41,35 +37,33 @@
},
"private": true,
"dependencies": {
"@angular/animations": "^20.3.26",
"@angular/cdk": "^20.2.14",
"@angular/common": "^20.3.26",
"@angular/compiler": "^20.3.26",
"@angular/core": "^20.3.26",
"@angular/forms": "^20.3.26",
"@angular/platform-browser": "^20.3.26",
"@angular/platform-browser-dynamic": "^20.3.26",
"@angular/router": "^20.3.26",
"@angular/animations": "^19.2.20",
"@angular/cdk": "^19.2.19",
"@angular/common": "^19.2.20",
"@angular/compiler": "^19.2.20",
"@angular/core": "^19.2.20",
"@angular/forms": "^19.2.20",
"@angular/platform-browser": "^19.2.20",
"@angular/platform-browser-dynamic": "^19.2.20",
"@angular/router": "^19.2.20",
"@cds/core": "^6.15.1",
"@clr/angular": "file:libraries/clr-angular-17.9.0.tgz",
"@clr/icons": "^13.0.2",
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
"@handsontable/angular-wrapper": "^18.0.0",
"@sasjs/adapter": "^4.17.3",
"@sasjs/utils": "^3.5.9",
"@handsontable/angular-wrapper": "^17.1.0",
"@sasjs/adapter": "^4.17.0",
"@sasjs/utils": "^3.5.3",
"@sheet/crypto": "file:libraries/sheet-crypto.tgz",
"@types/d3-graphviz": "^2.6.7",
"@types/text-encoding": "0.0.35",
"base64-arraybuffer": "^0.2.0",
"browserify-cipher": "^1.0.1",
"buffer": "^5.4.3",
"create-hash": "^1.2.0",
"create-hmac": "^1.1.7",
"crypto-browserify": "^3.12.1",
"crypto-js": "^4.2.0",
"d3-graphviz": "^5.0.2",
"exceljs": "^4.4.0",
"fs-extra": "^7.0.1",
"handsontable": "^18.0.0",
"handsontable": "^17.1.0",
"https-browserify": "1.0.0",
"hyperformula": "^2.5.0",
"iconv-lite": "^0.5.0",
@@ -79,8 +73,8 @@
"moment": "^2.30.1",
"ngx-clipboard": "^16.0.0",
"ngx-json-viewer": "file:libraries/ngx-json-viewer-3.2.1.tgz",
"nodejs": "0.0.0",
"os-browserify": "0.3.0",
"randombytes": "^2.1.0",
"rxjs": "^7.8.0",
"save-svg-as-png": "^1.4.17",
"stream-browserify": "3.0.0",
@@ -93,32 +87,29 @@
"zone.js": "~0.15.1"
},
"devDependencies": {
"@angular-devkit/build-angular": "^20.3.32",
"@angular-devkit/build-angular": "^19.2.24",
"@angular-eslint/builder": "19.8.1",
"@angular-eslint/eslint-plugin": "19.8.1",
"@angular-eslint/eslint-plugin-template": "19.8.1",
"@angular-eslint/schematics": "19.8.1",
"@angular-eslint/template-parser": "19.8.1",
"@angular/cli": "^20.3.32",
"@angular/compiler-cli": "^20.3.26",
"@angular/cli": "^19.2.24",
"@angular/compiler-cli": "^19.2.20",
"@babel/plugin-proposal-private-methods": "^7.18.6",
"@compodoc/compodoc": "^2.0.0",
"@compodoc/compodoc": "^1.2.1",
"@cypress/webpack-preprocessor": "^5.17.1",
"@lhci/cli": "^0.15.1",
"@types/core-js": "^2.5.5",
"@types/create-hash": "^1.2.6",
"@types/create-hmac": "^1.1.3",
"@types/crypto-js": "^4.2.1",
"@types/es6-shim": "^0.31.39",
"@types/jasmine": "~5.1.4",
"@types/lodash-es": "^4.17.3",
"@types/marked": "^4.3.0",
"@types/node": "12.20.50",
"@types/randombytes": "^2.0.3",
"@typescript-eslint/eslint-plugin": "8.65.0",
"@typescript-eslint/parser": "8.65.0",
"@typescript-eslint/eslint-plugin": "8.31.1",
"@typescript-eslint/parser": "8.31.1",
"core-js": "^2.5.4",
"cypress": "^15.19.0",
"cypress": "^15.14.2",
"cypress-file-upload": "^5.0.8",
"cypress-plugin-tab": "^1.0.5",
"cypress-real-events": "^1.8.1",
@@ -135,13 +126,12 @@
"lodash-es": "^4.17.21",
"mochawesome": "^7.1.3",
"mutationobserver-shim": "^0.3.3",
"prettier": "3.8.4",
"replace-in-file": "^6.3.5",
"rimraf": "3.0.2",
"ts-loader": "^9.2.8",
"ts-node": "^3.3.0",
"typescript": "~5.9.3",
"wait-on": "^9.0.10",
"typescript": "~5.8.3",
"wait-on": "^6.0.1",
"watch": "^1.0.2"
},
"overrides": {
+1 -7
View File
@@ -55,12 +55,7 @@ export interface HandsontableStaticConfig {
* Cached viyaApi collections, search and selected endpoint
*/
export const globals: {
embed: boolean | 'va'
/**
* VA embed filter behavior: 'live' = auto-apply, 'confirm' = manual Apply.
* Defaults to 'live'; toggled by the Auto-apply checkbox in the editor.
*/
vaApplyMode: 'live' | 'confirm'
embed: boolean
rootParam: string
dcLib: string
xlmaps: XLMapListItem[]
@@ -76,7 +71,6 @@ export const globals: {
[key: string]: any
} = {
embed: false,
vaApplyMode: 'live',
rootParam: <string>'',
dcLib: '',
xlmaps: [],
+255 -273
View File
@@ -1,287 +1,272 @@
<div class="main-container">
@if (!router.url.includes('licensing')) {
@if (freeTierBanner && (!licenseExpiringDays || licenseExpiringDays < 0)) {
<div
class="alert alert-app-level alert-warning"
id="demo-banner"
role="alert"
>
@if (licenceProblem.value === null) {
<div class="alert-items">
<div class="alert-item static">
<div class="alert-icon-wrapper">
<cds-icon
class="alert-icon"
shape="warning-standard"
></cds-icon>
</div>
<div class="alert-text">
Data Controller (FREE Tier) - to upgrade contact
<contact-link classes="color-white" />
</div>
</div>
</div>
<a routerLink="/licensing/update" class="update-key"
>Update Licence Key</a
>
}
@if (licenceProblem.value !== null) {
<div class="alert-items">
<div class="alert-item static">
<div class="alert-icon-wrapper">
<cds-icon
class="alert-icon"
shape="warning-standard"
></cds-icon>
</div>
<div class="alert-text">
Data Controller (FREE Tier) - Problem with licence
</div>
</div>
</div>
<a
(click)="licenceProblemDetails(licenceProblem.value)"
class="update-key cursor-pointer"
>More details</a
>
}
</div>
}
@if (licenseExpiringDays && !freeTierBanner) {
<div
class="alert alert-app-level alert-danger"
id="demo-banner"
role="alert"
>
<ng-container *ngIf="!router.url.includes('licensing')">
<div
*ngIf="
freeTierBanner && (!licenseExpiringDays || licenseExpiringDays < 0)
"
class="alert alert-app-level alert-warning"
id="demo-banner"
role="alert"
>
<ng-container *ngIf="licenceProblem.value === null">
<div class="alert-items">
<div class="alert-item static">
<div class="alert-icon-wrapper">
<cds-icon class="alert-icon" shape="warning-standard"></cds-icon>
</div>
<div class="alert-text">
This license key will expire in {{ licenseExpiringDays }}
{{ licenseExpiringDays === 1 ? 'day' : 'days' }}. Please contact
Data Controller (FREE Tier) - to upgrade contact
<contact-link classes="color-white" />
or your reseller to arrange additional licence for site id
{{ syssite.getValue() }}.
</div>
</div>
</div>
@if (!freeTierBanner) {
<a routerLink="/licensing/update" class="update-key"
>Update Licence Key</a
>
}
</div>
}
@if (appOverCapacity) {
<div
class="alert alert-app-level alert-danger"
id="demo-banner"
role="alert"
>
<div class="alert-items">
<div class="alert-item static">
<div class="alert-icon-wrapper">
<cds-icon class="alert-icon" shape="warning-standard"></cds-icon>
</div>
<div class="alert-text">
The registered number of users exceeds the limit specified for
your license. Please contact
<contact-link classes="color-white" />
or your reseller to arrange additional licence for site id
{{ syssite.getValue() }}.
</div>
</div>
</div>
@if (!licenseExpiringDays && !freeTierBanner) {
<a routerLink="/licensing/update" class="update-key"
>Update Licence Key</a
>
}
</div>
}
}
@if (!embed) {
<header class="app-header">
<!-- <button
<a routerLink="/licensing/update" class="update-key"
>Update Licence Key</a
>
</ng-container>
<ng-container *ngIf="licenceProblem.value !== null">
<div class="alert-items">
<div class="alert-item static">
<div class="alert-icon-wrapper">
<cds-icon class="alert-icon" shape="warning-standard"></cds-icon>
</div>
<div class="alert-text">
Data Controller (FREE Tier) - Problem with licence
</div>
</div>
</div>
<a
(click)="licenceProblemDetails(licenceProblem.value)"
class="update-key cursor-pointer"
>More details</a
>
</ng-container>
</div>
<div
*ngIf="licenseExpiringDays && !freeTierBanner"
class="alert alert-app-level alert-danger"
id="demo-banner"
role="alert"
>
<div class="alert-items">
<div class="alert-item static">
<div class="alert-icon-wrapper">
<cds-icon class="alert-icon" shape="warning-standard"></cds-icon>
</div>
<div class="alert-text">
This license key will expire in {{ licenseExpiringDays }}
{{ licenseExpiringDays === 1 ? 'day' : 'days' }}. Please contact
<contact-link classes="color-white" />
or your reseller to arrange additional licence for site id
{{ syssite.getValue() }}.
</div>
</div>
</div>
<a
*ngIf="!freeTierBanner"
routerLink="/licensing/update"
class="update-key"
>Update Licence Key</a
>
</div>
<div
*ngIf="appOverCapacity"
class="alert alert-app-level alert-danger"
id="demo-banner"
role="alert"
>
<div class="alert-items">
<div class="alert-item static">
<div class="alert-icon-wrapper">
<cds-icon class="alert-icon" shape="warning-standard"></cds-icon>
</div>
<div class="alert-text">
The registered number of users exceeds the limit specified for your
license. Please contact
<contact-link classes="color-white" />
or your reseller to arrange additional licence for site id
{{ syssite.getValue() }}.
</div>
</div>
</div>
<a
*ngIf="!licenseExpiringDays && !freeTierBanner"
routerLink="/licensing/update"
class="update-key"
>Update Licence Key</a
>
</div>
</ng-container>
<header class="app-header" *ngIf="!embed">
<!-- <button
*ngIf="
isMainRoute('view') ||
(isMainRoute('home') && !router.url.includes('licensing'))
"
class="header-hamburger-trigger"
(click)="toggleSidebar()"
type="button"
>
<span></span>
</button> -->
@if (
class="header-hamburger-trigger"
(click)="toggleSidebar()"
type="button"
>
<span></span>
</button> -->
<div
*ngIf="
isMainRoute('view') ||
(isMainRoute('home') && !router.url.includes('licensing'))
) {
<div
(click)="toggleSidebar()"
type="button"
aria-label="Toggle sidebar"
class="cursor-pointer select-none ml-10 d-flex clr-justify-content-center clr-align-items-center"
>
<clr-icon size="24" shape="tree-view" aria-hidden="true"></clr-icon>
</div>
}
<div class="logo d-flex clr-align-items-center">
@if (!router.url.includes('deploy')) {
<a href="#" [routerLink]="['/']" class="nav-link">
<img
class="without-text d-block d-md-none"
src="images/dc-logo.svg"
alt="datacontroller logo without text"
/>
<img
class="with-text d-none d-md-block"
src="images/datacontroller.svg"
alt="datacontroller logo"
/>
</a>
}
@if (router.url.includes('deploy')) {
<a>
<span class="clr-icon header-logo ml-10"></span>
</a>
}
</div>
@if (
!router.url.includes('deploy') && !router.url.includes('licensing')
) {
<div class="header-nav d-flex d-sm-none">
<clr-dropdown>
<button
class="nav-icon color-white-i"
clrDropdownTrigger
aria-label="toggle settings menu"
>
Menu
<!-- <clr-icon size="20" shape="bars"></clr-icon> -->
</button>
<clr-dropdown-menu *clrIfOpen clrPosition="bottom-left">
<a [routerLink]="['/view']" clrDropdownItem>VIEW</a>
<a [routerLink]="['/home']" clrDropdownItem>LOAD</a>
<a [routerLink]="['/review/submitted']" clrDropdownItem>REVIEW</a>
</clr-dropdown-menu>
</clr-dropdown>
</div>
<div class="header-nav d-none d-sm-flex">
<a
[routerLink]="['/view']"
class="nav-link nav-text"
routerLinkActive="active"
>VIEW</a
>
<a
[routerLink]="['/home']"
class="nav-link nav-text"
[class.active]="
router.url.includes('editor') ||
router.url.includes('edit-record') ||
router.url.includes('home')
"
>LOAD</a
>
<a
[routerLink]="['/review/submitted']"
[class.active]="
router.url.includes('submitted') ||
router.url.includes('approve') ||
router.url.includes('history')
"
class="nav-link nav-text cursor-pointer"
>REVIEW</a
>
</div>
}
<app-header-actions></app-header-actions>
</header>
}
@if (
!embed &&
(router.url.includes('submitted') ||
router.url.includes('approve') ||
router.url.includes('history'))
) {
<nav class="subnav">
<ul class="nav">
<li class="nav-item">
<a
[routerLink]="['/review/submitted']"
class="nav-link nav-text"
routerLinkActive="active"
>SUBMIT</a
>
</li>
<li class="nav-item">
<a
[routerLink]="['/review/approve']"
class="nav-link nav-text"
[class.active]="router.url.includes('approve')"
routerLinkActive="active"
>APPROVE</a
>
</li>
<li class="nav-item">
<a
[routerLink]="['/review/history']"
class="nav-link nav-text"
routerLinkActive="active"
>HISTORY</a
>
</li>
</ul>
</nav>
}
"
(click)="toggleSidebar()"
type="button"
aria-label="Toggle sidebar"
class="cursor-pointer select-none ml-10 d-flex clr-justify-content-center clr-align-items-center"
>
<clr-icon size="24" shape="tree-view" aria-hidden="true"></clr-icon>
</div>
@if (!errTop) {
<app-alerts></app-alerts>
}
<div class="logo d-flex clr-align-items-center">
<a
*ngIf="!router.url.includes('deploy')"
href="#"
[routerLink]="['/']"
class="nav-link"
>
<img
class="without-text d-block d-md-none"
src="images/dc-logo.svg"
alt="datacontroller logo without text"
/>
<img
class="with-text d-none d-md-block"
src="images/datacontroller.svg"
alt="datacontroller logo"
/>
</a>
<a *ngIf="router.url.includes('deploy')">
<span class="clr-icon header-logo ml-10"></span>
</a>
</div>
<ng-container
*ngIf="
!router.url.includes('deploy') && !router.url.includes('licensing')
"
>
<div class="header-nav d-flex d-sm-none">
<clr-dropdown>
<button
class="nav-icon color-white-i"
clrDropdownTrigger
aria-label="toggle settings menu"
>
Menu
<!-- <clr-icon size="20" shape="bars"></clr-icon> -->
</button>
<clr-dropdown-menu *clrIfOpen clrPosition="bottom-left">
<a [routerLink]="['/view']" clrDropdownItem>VIEW</a>
<a [routerLink]="['/home']" clrDropdownItem>LOAD</a>
<a [routerLink]="['/review/submitted']" clrDropdownItem>REVIEW</a>
</clr-dropdown-menu>
</clr-dropdown>
</div>
<div class="header-nav d-none d-sm-flex">
<a
[routerLink]="['/view']"
class="nav-link nav-text"
routerLinkActive="active"
>VIEW</a
>
<a
[routerLink]="['/home']"
class="nav-link nav-text"
[class.active]="
router.url.includes('editor') ||
router.url.includes('edit-record') ||
router.url.includes('home')
"
>LOAD</a
>
<a
[routerLink]="['/review/submitted']"
[class.active]="
router.url.includes('submitted') ||
router.url.includes('approve') ||
router.url.includes('history')
"
class="nav-link nav-text cursor-pointer"
>REVIEW</a
>
</div>
</ng-container>
<app-header-actions></app-header-actions>
</header>
<nav
*ngIf="
!embed &&
(router.url.includes('submitted') ||
router.url.includes('approve') ||
router.url.includes('history'))
"
class="subnav"
>
<ul class="nav">
<li class="nav-item">
<a
[routerLink]="['/review/submitted']"
class="nav-link nav-text"
routerLinkActive="active"
>SUBMIT</a
>
</li>
<li class="nav-item">
<a
[routerLink]="['/review/approve']"
class="nav-link nav-text"
[class.active]="router.url.includes('approve')"
routerLinkActive="active"
>APPROVE</a
>
</li>
<li class="nav-item">
<a
[routerLink]="['/review/history']"
class="nav-link nav-text"
routerLinkActive="active"
>HISTORY</a
>
</li>
</ul>
</nav>
<app-alerts *ngIf="!errTop"></app-alerts>
<app-requests-modal [(opened)]="requestsModal"></app-requests-modal>
<app-excel-password-modal></app-excel-password-modal>
<!-- <app-terms *ngIf="showRegistration"></app-terms> -->
<!-- VA embed, back to Editor button -->
@if (embed === 'va' && vaEditorLibds && !isMainRoute('/editor')) {
<div class="va-back-bar">
<button
type="button"
class="btn btn-sm btn-primary"
(click)="backToEditor()"
>
<clr-icon
aria-hidden="true"
shape="caret"
dir="left"
size="16"
></clr-icon>
Back to Edit table
</button>
</div>
}
@if (startupDataLoaded) {
<router-outlet></router-outlet>
}
<router-outlet *ngIf="startupDataLoaded"></router-outlet>
<app-login></app-login>
@if (errTop) {
<app-alerts></app-alerts>
}
@for (abort of sasjsAborts; track abort) {
<app-info-modal
[data]="abort"
[forceReload]="!startupDataLoaded && sasjsAborts.length === 1"
(onConfirmModalClick)="closeAbortModal(abort.id!)"
>
</app-info-modal>
}
<app-alerts *ngIf="errTop"></app-alerts>
<app-info-modal
*ngFor="let abort of sasjsAborts"
[data]="abort"
[forceReload]="!startupDataLoaded && sasjsAborts.length === 1"
(onConfirmModalClick)="closeAbortModal(abort.id!)"
>
</app-info-modal>
<clr-modal
appDragNdrop
@@ -304,20 +289,17 @@
</div>
<!-- App Loading Page -->
@if (!startupDataLoaded) {
<div class="app-loading">
<img
class="loading-logo"
src="images/datacontroller.svg"
alt="datacontroller logo"
/>
@if (appActive === null) {
<div class="slider">
<div class="line"></div>
<div class="subline inc"></div>
<div class="subline dec"></div>
</div>
}
<div *ngIf="!startupDataLoaded" class="app-loading">
<img
class="loading-logo"
src="images/datacontroller.svg"
alt="datacontroller logo"
/>
<div *ngIf="appActive === null" class="slider">
<div class="line"></div>
<div class="subline inc"></div>
<div class="subline dec"></div>
</div>
}
</div>
<!-- /App Loading Page -->
-10
View File
@@ -1,10 +0,0 @@
.va-back-bar {
position: sticky;
top: 0;
z-index: 1000;
display: flex;
justify-content: flex-start;
padding: 0.35rem 0.6rem;
background: #fafafa;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
+7 -24
View File
@@ -11,7 +11,6 @@ import { Location } from '@angular/common'
import '@clr/icons'
import '@clr/icons/shapes/all-shapes'
import { globals } from './_globals'
import { parseEmbedParam } from './shared/utils/parse-embed-param'
import moment from 'moment'
import { EventService } from './services/event.service'
import { AppService } from './services/app.service'
@@ -146,10 +145,13 @@ export class AppComponent {
})
const hashQuery = window.location.hash.split('?')[1]
if (hashQuery && new URLSearchParams(hashQuery).get('embed') !== null) {
const embed = parseEmbedParam(hashQuery)
globals.embed = embed
this.embed = embed
if (hashQuery) {
const embedParam = new URLSearchParams(hashQuery).get('embed')
if (embedParam !== null) {
const isEmbed = embedParam !== 'false'
globals.embed = isEmbed
this.embed = isEmbed
}
}
this.subscribeToShowAbortModal()
@@ -352,23 +354,4 @@ export class AppComponent {
public openLicencingPage() {
this.router.navigateByUrl('/licensing/update')
}
/** "lib.table" of the last-opened editor table (for the VA embed back button). */
get vaEditorLibds(): string {
const e = globals.editor
return e?.library && e?.table ? `${e.library}.${e.table}` : ''
}
/**
* VA embed only: returns to the editor for the last-opened table. Lets users
* who land on the stage/approve/review pages get back without knowing DC's
* page paths. `globals.embed` (set at app init) keeps the editor in VA mode.
*/
public backToEditor(): void {
if (this.vaEditorLibds) {
this.router.navigate(['/editor/' + this.vaEditorLibds], {
queryParams: { embed: 'va' }
})
}
}
}
+15 -16
View File
@@ -8,15 +8,7 @@ import { RouterModule, Routes } from '@angular/router'
import { NotFoundComponent } from './not-found/not-found.component'
import { DeployModule } from './deploy/deploy.module'
import { EditorModule } from './editor/editor.module'
import { HomeModule } from './home/home.module'
import { LicensingModule } from './licensing/licensing.module'
import { ReviewModule } from './review/review.module'
import { ReviewRouteComponent } from './routes/review-route/review-route.component'
import { StageModule } from './stage/stage.module'
import { SystemModule } from './system/system.module'
import { ViewerModule } from './viewer/viewer.module'
/**
* Defining routes
@@ -25,7 +17,8 @@ export const ROUTES: Routes = [
{ path: '', redirectTo: 'home', pathMatch: 'full' },
{
path: 'view',
loadChildren: () => ViewerModule
loadChildren: () =>
import('./viewer/viewer.module').then((m) => m.ViewerModule)
},
{
/**
@@ -37,36 +30,42 @@ export const ROUTES: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'toapprove' },
{
path: '',
loadChildren: () => ReviewModule
loadChildren: () =>
import('./review/review.module').then((m) => m.ReviewModule)
}
]
},
{
path: 'licensing',
loadChildren: () => LicensingModule
loadChildren: () =>
import('./licensing/licensing.module').then((m) => m.LicensingModule)
},
{
path: 'home',
loadChildren: () => HomeModule
loadChildren: () => import('./home/home.module').then((m) => m.HomeModule)
},
{
/**
* Load editor module with subroutes
*/
path: 'editor',
loadChildren: () => EditorModule
loadChildren: () =>
import('./editor/editor.module').then((m) => m.EditorModule)
},
{
path: 'stage',
loadChildren: () => StageModule
loadChildren: () =>
import('./stage/stage.module').then((m) => m.StageModule)
},
{
path: 'system',
loadChildren: () => SystemModule
loadChildren: () =>
import('./system/system.module').then((m) => m.SystemModule)
},
{
path: 'deploy',
loadChildren: () => DeployModule
loadChildren: () =>
import('./deploy/deploy.module').then((m) => m.DeployModule)
},
{ path: '**', component: NotFoundComponent }
]
+91 -92
View File
@@ -1,101 +1,100 @@
<main class="content-area position-relative">
<div class="clr-row">
<!-- T&C section -->
@if (step === 0) {
<div id="TCS" class="card">
<div class="card-header">Terms and Conditions</div>
<div class="card-block">
<div class="card-text">
<p class="mt-0">
The Demo version of Data Controller is free for EVALUATION
purposes only. Before proceeding with configuration, please
confirm that you have read, understood, and agreed to the
<a
href="https://docs.datacontroller.io/evaluation-licence-agreement"
target="_blank"
>Data Controller for SAS&#169; Evaluation Agreement</a
>.
</p>
</div>
<hr class="light" />
<clr-checkbox-wrapper>
<input clrCheckbox type="checkbox" (change)="termsAgreeChange()" />
<label
>I have read and agree to the terms of the
<a
href="https://docs.datacontroller.io/evaluation-licence-agreement"
target="_blank"
>Data Controller for SAS&#169; Evaluation Agreement</a
></label
>
</clr-checkbox-wrapper>
<!-- <hr />
<div class="clr-checkbox-wrapper">
<input
[(ngModel)]="autodeploy"
type="checkbox"
id="checkbox2"
class="clr-checkbox"
checked
/>
<label for="checkbox2"
>Autodeploy
{{ !jsonFile ? '(json file is not available)' : '' }}</label
>
</div> -->
<div *ngIf="step === 0" id="TCS" class="card">
<div class="card-header">Terms and Conditions</div>
<div class="card-block">
<div class="card-text">
<p class="mt-0">
The Demo version of Data Controller is free for EVALUATION purposes
only. Before proceeding with configuration, please confirm that you
have read, understood, and agreed to the
<a
href="https://docs.datacontroller.io/evaluation-licence-agreement"
target="_blank"
>Data Controller for SAS&#169; Evaluation Agreement</a
>.
</p>
</div>
<hr class="light" />
<clr-checkbox-wrapper>
<input clrCheckbox type="checkbox" (change)="termsAgreeChange()" />
<label
>I have read and agree to the terms of the
<a
href="https://docs.datacontroller.io/evaluation-licence-agreement"
target="_blank"
>Data Controller for SAS&#169; Evaluation Agreement</a
></label
>
</clr-checkbox-wrapper>
<!-- <hr />
<div class="clr-checkbox-wrapper">
<input
[(ngModel)]="autodeploy"
type="checkbox"
id="checkbox2"
class="clr-checkbox"
checked
/>
<label for="checkbox2"
>Autodeploy
{{ !jsonFile ? '(json file is not available)' : '' }}</label
>
</div> -->
</div>
}
</div>
</div>
<!-- T&C section end -->
@if (step > 0) {
@switch (true) {
@case (sasJsConfig.serverType === ServerType.SasViya) {
@if (autodeploy) {
<div class="autodeploy-section card">
<app-automatic-deploy
[sasJs]="sasJs"
[sasJsConfig]="sasJsConfig"
[dcAdapterSettings]="dcAdapterSettings"
[appLoc]="appLoc"
[dcPath]="dcPath"
[selectedAdminGroup]="selectedAdminGroup"
(onNavigateToHome)="onNavigateToHome()"
></app-automatic-deploy>
</div>
}
@if (!autodeploy) {
<div id="mainbody" class="card">
<app-manual-deploy
[sasJs]="sasJs"
[sasJsConfig]="sasJsConfig"
[dcAdapterSettings]="dcAdapterSettings"
(onNavigateToHome)="onNavigateToHome()"
></app-manual-deploy>
</div>
}
}
@case (sasJsConfig.serverType === ServerType.Sasjs) {
<div class="autodeploy-section card">
<app-sasjs-configurator
[sasJs]="sasJs"
[sasJsConfig]="sasJsConfig"
[dcAdapterSettings]="dcAdapterSettings"
(onNavigateToHome)="onNavigateToHome()"
></app-sasjs-configurator>
</div>
}
@case (sasJsConfig.serverType === ServerType.Sas9) {
<div class="autodeploy-section card">
<app-sasjs-configurator
[sasJs]="sasJs"
[sasJsConfig]="sasJsConfig"
[dcAdapterSettings]="dcAdapterSettings"
(onNavigateToHome)="onNavigateToHome()"
></app-sasjs-configurator>
</div>
}
}
}
<ng-container *ngIf="step > 0" [ngSwitch]="true">
<ng-container *ngSwitchCase="sasJsConfig.serverType === ServerType.SasViya">
<div *ngIf="autodeploy" class="autodeploy-section card">
<app-automatic-deploy
[sasJs]="sasJs"
[sasJsConfig]="sasJsConfig"
[dcAdapterSettings]="dcAdapterSettings"
[appLoc]="appLoc"
[dcPath]="dcPath"
[selectedAdminGroup]="selectedAdminGroup"
(onNavigateToHome)="onNavigateToHome()"
></app-automatic-deploy>
</div>
<div *ngIf="!autodeploy" id="mainbody" class="card">
<app-manual-deploy
[sasJs]="sasJs"
[sasJsConfig]="sasJsConfig"
[dcAdapterSettings]="dcAdapterSettings"
(onNavigateToHome)="onNavigateToHome()"
></app-manual-deploy>
</div>
</ng-container>
<ng-container *ngSwitchCase="sasJsConfig.serverType === ServerType.Sasjs">
<div class="autodeploy-section card">
<app-sasjs-configurator
[sasJs]="sasJs"
[sasJsConfig]="sasJsConfig"
[dcAdapterSettings]="dcAdapterSettings"
(onNavigateToHome)="onNavigateToHome()"
></app-sasjs-configurator>
</div>
</ng-container>
<ng-container *ngSwitchCase="sasJsConfig.serverType === ServerType.Sas9">
<div class="autodeploy-section card">
<app-sasjs-configurator
[sasJs]="sasJs"
[sasJsConfig]="sasJsConfig"
[dcAdapterSettings]="dcAdapterSettings"
(onNavigateToHome)="onNavigateToHome()"
></app-sasjs-configurator>
</div>
</ng-container>
</ng-container>
</main>
@@ -1,80 +1,91 @@
@if (autodeploying) {
<div class="auto-deploy">
<div class="spinner-box">
@if (!autodeployDone) {
<span class="spinner spinner-md"> Loading... </span>
<p>Deploying...</p>
}
@if (autodeployDone) {
<p class="m-0 align-self-start">Done</p>
<hr class="w-100" />
@if (autoDeployStatus.deployServicePack !== null) {
<div class="deploy-status-row">
@if (autoDeployStatus.deployServicePack === true) {
<clr-icon
class="deploy-success"
shape="success-standard"
></clr-icon>
}
@if (!autoDeployStatus.deployServicePack === false) {
<clr-icon class="deploy-error" shape="times-circle"></clr-icon>
}
<p>Deploy SAS Jobs</p>
</div>
}
@if (autoDeployStatus.runMakeData !== null) {
<div class="deploy-status-row">
@if (autoDeployStatus.runMakeData) {
<clr-icon
class="deploy-success"
shape="success-standard"
></clr-icon>
}
@if (autoDeployStatus.runMakeData === false) {
<clr-icon class="deploy-error" shape="times-circle"></clr-icon>
}
<p>Create database</p>
</div>
}
<hr class="w-100" />
<div class="buttons">
<button (click)="navigateToHome()" class="btn btn-primary mt-15 mr-0">
@if (
<div *ngIf="autodeploying" class="auto-deploy">
<div class="spinner-box">
<ng-container *ngIf="!autodeployDone">
<span class="spinner spinner-md"> Loading... </span>
<p>Deploying...</p>
</ng-container>
<ng-container *ngIf="autodeployDone">
<p class="m-0 align-self-start">Done</p>
<hr class="w-100" />
<div
*ngIf="autoDeployStatus.deployServicePack !== null"
class="deploy-status-row"
>
<clr-icon
*ngIf="autoDeployStatus.deployServicePack === true"
class="deploy-success"
shape="success-standard"
></clr-icon>
<clr-icon
*ngIf="!autoDeployStatus.deployServicePack === false"
class="deploy-error"
shape="times-circle"
></clr-icon>
<p>Deploy SAS Jobs</p>
</div>
<div
*ngIf="autoDeployStatus.runMakeData !== null"
class="deploy-status-row"
>
<clr-icon
*ngIf="autoDeployStatus.runMakeData"
class="deploy-success"
shape="success-standard"
></clr-icon>
<clr-icon
*ngIf="autoDeployStatus.runMakeData === false"
class="deploy-error"
shape="times-circle"
></clr-icon>
<p>Create database</p>
</div>
<hr class="w-100" />
<div class="buttons">
<button (click)="navigateToHome()" class="btn btn-primary mt-15 mr-0">
<clr-icon
*ngIf="
autoDeployStatus.deployServicePack === false ||
autoDeployStatus.runMakeData === false
) {
<clr-icon class="deploy-error" shape="times-circle"></clr-icon>
}
LAUNCH
</button>
<button
(click)="
downloadFile(makeDataResponse, 'create-database-log', 'txt')
"
class="btn btn-primary-outline mt-15 mr-0"
>
Download log
</button>
</div>
<hr class="w-100" />
<div class="buttons">
<button
(click)="autodeploying = false; autodeployDone = false"
class="btn btn-primary-outline mt-15 mr-0 align-self-end"
>
Close
</button>
<button
(click)="openSasRequestsModal()"
class="btn btn-primary-outline mt-15 mr-0 align-self-end"
>
SAS Requests
</button>
</div>
}
</div>
class="deploy-error"
shape="times-circle"
></clr-icon>
LAUNCH
</button>
<button
(click)="downloadFile(makeDataResponse, 'create-database-log', 'txt')"
class="btn btn-primary-outline mt-15 mr-0"
>
Download log
</button>
</div>
<hr class="w-100" />
<div class="buttons">
<button
(click)="autodeploying = false; autodeployDone = false"
class="btn btn-primary-outline mt-15 mr-0 align-self-end"
>
Close
</button>
<button
(click)="openSasRequestsModal()"
class="btn btn-primary-outline mt-15 mr-0 align-self-end"
>
SAS Requests
</button>
</div>
</ng-container>
</div>
}
</div>
<h4 class="text-center my-15">Viya Deploy</h4>
<hr />
@@ -95,18 +106,21 @@
<label for="dcloc" class="mt-20 clr-control-label">SAS Admin group</label>
<div class="mb-10 clr-control-container">
<div class="clr-input-wrapper small-mt">
@if (!adminGroupsLoading) {
<select clrSelect name="options" [(ngModel)]="selectedAdminGroup">
@for (adminGroup of adminGroups; track adminGroup) {
<option [value]="adminGroup.id">
{{ adminGroup.name }}
</option>
}
</select>
}
@if (adminGroupsLoading) {
<clr-spinner clrInline class="spinner-sm"></clr-spinner>
}
<select
*ngIf="!adminGroupsLoading"
clrSelect
name="options"
[(ngModel)]="selectedAdminGroup"
>
<option *ngFor="let adminGroup of adminGroups" [value]="adminGroup.id">
{{ adminGroup.name }}
</option>
</select>
<clr-spinner
clrInline
class="spinner-sm"
*ngIf="adminGroupsLoading"
></clr-spinner>
</div>
</div>
@@ -115,34 +129,36 @@
>
<div class="mb-10 clr-control-container">
<div class="clr-input-wrapper small-mt">
@if (!computeContextsLoading) {
<select
clrSelect
name="options"
(ngModelChange)="onComputeContextChange($event)"
[(ngModel)]="selectedComputeContext"
<select
*ngIf="!computeContextsLoading"
clrSelect
name="options"
(ngModelChange)="onComputeContextChange($event)"
[(ngModel)]="selectedComputeContext"
>
<option
*ngFor="let computeContext of computeContexts"
[value]="computeContext.id"
>
@for (computeContext of computeContexts; track computeContext) {
<option [value]="computeContext.id">
{{ computeContext.name }}
</option>
}
</select>
}
@if (computeContextsLoading) {
<clr-spinner clrInline class="spinner-sm"></clr-spinner>
}
{{ computeContext.name }}
</option>
</select>
<clr-spinner
clrInline
class="spinner-sm"
*ngIf="computeContextsLoading"
></clr-spinner>
</div>
</div>
@if (runningAsUser) {
<ng-container *ngIf="runningAsUser">
<label for="dcloc" class="mt-20 clr-control-label">Running as user:</label>
<div class="mb-10 clr-control-container">
<div class="clr-input-wrapper">
<p class="mt-0">{{ runningAsUser }}</p>
</div>
</div>
}
</ng-container>
<!-- Keeping this for a reference in case future VIYA changes and starts allowing separate backend and frontend) -->
@@ -153,7 +169,7 @@
(click)="recreateDatabaseClicked($event)"
type="checkbox"
checked
/>
/>
<label>Recreate database</label>
</clr-checkbox-wrapper> -->
@@ -169,18 +185,18 @@
<!-- Keeping this for a reference in case future VIYA changes and starts allowing separate backend and frontend) -->
<!-- <button
(click)="executeJson()"
class="btn-autodeploy btn btn-primary d-inline-block mr-10"
[disabled]="!jsonFile"
(click)="executeJson()"
class="btn-autodeploy btn btn-primary d-inline-block mr-10"
[disabled]="!jsonFile"
>
Deploy {{ !jsonFile ? '(json file is not available)' : '' }}
Deploy {{ !jsonFile ? '(json file is not available)' : '' }}
</button> -->
<!-- <button
(click)="uploadJsonAuto.click()"
class="btn-autodeploy btn btn-primary d-inline-block mr-10"
(click)="uploadJsonAuto.click()"
class="btn-autodeploy btn btn-primary d-inline-block mr-10"
>
Upload different file to deploy
Upload different file to deploy
</button>
<input
#uploadJsonAuto
@@ -188,7 +204,7 @@ Upload different file to deploy
hidden
(click)="clearUploadInput($event)"
(change)="onJsonFileChange($event)"
/> -->
/> -->
<clr-modal [(clrModalOpen)]="recreateDatabaseModal" [clrModalClosable]="false">
<h3 class="modal-title">Warning</h3>
@@ -10,29 +10,29 @@
</div>
<div class="card-block">
<!-- <div class="card-title">
Client Details
</div> -->
Client Details
</div> -->
@if (needsLogin) {
<div id="loginForm" class="d-none">
<p class="mb-10">Please log in first</p>
<label for="username" class="clr-control-label">Username</label>
<div class="mb-10 clr-control-container">
<div class="clr-input-wrapper">
<input type="text" id="username" class="clr-input" />
</div>
<div *ngIf="needsLogin" id="loginForm" class="d-none">
<p class="mb-10">Please log in first</p>
<label for="username" class="clr-control-label">Username</label>
<div class="mb-10 clr-control-container">
<div class="clr-input-wrapper">
<input type="text" id="username" class="clr-input" />
</div>
<label for="password" class="clr-control-label">Password</label>
<div class="mb-10 clr-control-container">
<div class="clr-input-wrapper">
<input type="password" id="password" class="clr-input" />
</div>
</div>
<button class="btn btn-primary d-none" id="loginBtn">Log in</button>
</div>
}
@if (!needsLogin) {
<label for="password" class="clr-control-label">Password</label>
<div class="mb-10 clr-control-container">
<div class="clr-input-wrapper">
<input type="password" id="password" class="clr-input" />
</div>
</div>
<button class="btn btn-primary d-none" id="loginBtn">Log in</button>
</div>
<ng-container *ngIf="!needsLogin">
<form>
<div class="clr-form-control">
<label for="select-full" class="clr-control-label">Admin group</label>
@@ -45,15 +45,17 @@
id="adminGroupsSelect"
class="clr-select"
>
@for (adminGroup of adminGroups; track adminGroup) {
<option [value]="adminGroup.id">
{{ adminGroup.name }}
</option>
}
<option
*ngFor="let adminGroup of adminGroups"
[value]="adminGroup.id"
>
{{ adminGroup.name }}
</option>
</select>
</div>
</div>
</div>
<div class="clr-form-control">
<div [class.hidden]="contextsLoading">
<label for="select-full" class="clr-control-label">Context</label>
@@ -65,14 +67,15 @@
name="selectedContext"
class="clr-select"
>
@for (context of allContexts; track context) {
<option [value]="context.name">
{{ context.name }}
@if ((context.attributes | json) != '{}') {
<span>( {{ context.attributes.sysUserId }} )</span>
}
</option>
}
<option
*ngFor="let context of allContexts"
[value]="context.name"
>
{{ context.name }}
<span *ngIf="(context.attributes | json) != '{}'"
>( {{ context.attributes.sysUserId }} )</span
>
</option>
</select>
</div>
</div>
@@ -85,12 +88,14 @@
<clr-icon shape="play"></clr-icon>
</button>
</div>
<div [class.hidden]="!contextsLoading" class="d-flex">
<span class="spinner spinner-inline mr-10">
Loading contexts...
</span>
<span> Loading contexts... </span>
</div>
<label for="dcloc" class="mt-20 clr-control-label">DC Loc</label>
<div class="mb-10 clr-control-container">
<div class="clr-input-wrapper">
@@ -105,6 +110,7 @@
/>
</div>
</div>
<p class="mt-10">
Select JSON file to upload (json build file preloaded):
</p>
@@ -115,9 +121,10 @@
(change)="onJsonFileChange($event)"
/>
<!-- <button *ngIf="downloadFileBtn" (click)="downloadSasPrecodeFile()" style="width: 40px; min-width: 0;" class="btn btn-sm btn-icon">
<clr-icon shape="download"></clr-icon>
</button> -->
<clr-icon shape="download"></clr-icon>
</button> -->
</div>
<div class="mt-20 d-flex align-items-center">
<button
(click)="executeJson()"
@@ -128,13 +135,13 @@
>
SUBMIT JSON
</button>
@if (isJsonSubmitted) {
<span>JSON Submitted Successfully</span>
}
<span *ngIf="isJsonSubmitted">JSON Submitted Successfully</span>
<!-- <span *ngIf="executingScript" class="spinner spinner-inline ml-3">
Loading...
</span> -->
Loading...
</span> -->
</div>
<p class="mt-10">Select SAS file to upload:</p>
<div class="d-flex flex-column">
<input
@@ -142,15 +149,15 @@
(click)="clearUploadInput($event)"
(change)="onSasFileChange($event); downloadFileBtn = true"
/>
@if (downloadFileBtn) {
<button
(click)="downloadSasPrecodeFile()"
class="btn btn-sm btn-icon min-w-0 w-40"
>
<clr-icon shape="download"></clr-icon>
</button>
}
<button
*ngIf="downloadFileBtn"
(click)="downloadSasPrecodeFile()"
class="btn btn-sm btn-icon min-w-0 w-40"
>
<clr-icon shape="download"></clr-icon>
</button>
</div>
<div class="mt-20 d-flex align-items-center">
<button
(click)="executeSAS()"
@@ -161,14 +168,17 @@
>
SUBMIT
</button>
<!-- <span *ngIf="executingScript" class="spinner spinner-inline ml-3">
Loading...
</span> -->
Loading...
</span> -->
</div>
</div>
@if (jobLog.length > 0) {
<ng-container *ngIf="jobLog.length > 0">
<p class="mb-0 mt-10">File execute completed</p>
<hr />
<div>
<button
(click)="downloadFile(jobLog, 'execute-script-log', 'txt')"
@@ -177,7 +187,8 @@
Download log
</button>
</div>
}
</ng-container>
<button
[clrLoading]="createDatabaseLoading"
(click)="createDatabase()"
@@ -185,45 +196,52 @@
>
Create Database
</button>
@if (makeDataResponse.length > 0) {
<ng-container *ngIf="makeDataResponse.length > 0">
<p class="mb-0 mt-10">Create Database Completed</p>
<hr />
@if (makeDataResponse.length > 0) {
<div class="log-wrapper">
{{ makeDataResponse }}
</div>
}
<div *ngIf="makeDataResponse.length > 0" class="log-wrapper">
{{ makeDataResponse }}
</div>
<button (click)="navigateToHome()" class="btn btn-primary mt-15">
Let's get started
</button>
<button
(click)="downloadFile(makeDataResponse, 'create-database-log', 'txt')"
class="btn btn-primary mt-15"
>
Download log
</button>
<button (click)="validateDeploy()" class="btn btn-primary mt-15">
Validate
</button>
@if (validationState !== 'none' || isValidating) {
<div class="validation-bar">
@if (isValidating) {
<span class="spinner spinner-inline mr-10">
Validating deploy...
</span>
<span> Validating deploy... </span>
}
@if (!isValidating && validationState === 'error') {
<clr-icon shape="exclamation-circle" class="is-error"></clr-icon>
<span> Validation failed </span>
}
@if (!isValidating && validationState === 'success') {
<clr-icon shape="check-circle" class="is-success"></clr-icon>
<span> Validation succeeded </span>
}
</div>
}
}
<div
*ngIf="validationState !== 'none' || isValidating"
class="validation-bar"
>
<ng-container *ngIf="isValidating">
<span class="spinner spinner-inline mr-10">
Validating deploy...
</span>
<span> Validating deploy... </span>
</ng-container>
<ng-container *ngIf="!isValidating && validationState === 'error'">
<clr-icon shape="exclamation-circle" class="is-error"></clr-icon>
<span> Validation failed </span>
</ng-container>
<ng-container *ngIf="!isValidating && validationState === 'success'">
<clr-icon shape="check-circle" class="is-success"></clr-icon>
<span> Validation succeeded </span>
</ng-container>
</div>
</ng-container>
</form>
}
</ng-container>
</div>
@@ -1,8 +1,6 @@
@if (loading) {
<div class="thinProgress progresStatic progress loop">
<progress></progress>
</div>
}
<div *ngIf="loading" class="thinProgress progresStatic progress loop">
<progress></progress>
</div>
<h4 class="text-center my-15">Sasjs Deploy</h4>
<hr class="light" />
@@ -29,13 +27,13 @@
</div>
<!-- <button
(click)="directoryBrowse.click()"
class="mt-15 text-center"
class="btn btn-sm btn-outline"
>
Browse
</button>
<input #directoryBrowse hidden (change)="dirChange($event)" type="file" id="ctrl" webkitdirectory directory multiple/> -->
(click)="directoryBrowse.click()"
class="mt-15 text-center"
class="btn btn-sm btn-outline"
>
Browse
</button>
<input #directoryBrowse hidden (change)="dirChange($event)" type="file" id="ctrl" webkitdirectory directory multiple/> -->
</div>
<p class="m-0 mt-10">
@@ -47,11 +45,12 @@
<label class="mt-20 clr-control-label">Data Controller Admin group</label>
<clr-select-container class="mb-10 mt-0 w-50vw">
<select [(ngModel)]="dcAdminGroup" clrSelect>
@for (adminGroup of dcAdminGroupList; track adminGroup) {
<option [value]="adminGroup.GROUPNAME">
{{ adminGroup.GROUPNAME }} - {{ adminGroup.GROUPDESC }}
</option>
}
<option
*ngFor="let adminGroup of dcAdminGroupList"
[value]="adminGroup.GROUPNAME"
>
{{ adminGroup.GROUPNAME }} - {{ adminGroup.GROUPDESC }}
</option>
</select>
</clr-select-container>
@@ -84,20 +84,6 @@
status="warning"
></clr-icon>
<!-- SOFTREGEX: value doesn't match the expected pattern, but
submission is still allowed (unlike HARDREGEX, which is
surfaced via the existing invalid-data class below). -->
<clr-icon
*ngIf="
!currentRecordErrors.includes(colIndex) &&
currentRecordWarningCols.includes(col.key)
"
class="flex-unset position-absolute entry-input-left-offset"
shape="error-standard"
status="warning"
title="Value does not match the expected pattern"
></clr-icon>
<ng-container *ngSwitchCase="'numeric'">
<clr-input-container
*ngIf="
@@ -55,7 +55,6 @@ export class EditRecordComponent implements OnInit {
@Output() onPreviousRecord: EventEmitter<any> = new EventEmitter<any>()
public currentRecordInvalidCols: string[] = []
public currentRecordWarningCols: string[] = []
public generateEditRecordUrlLoading: boolean = false
public generatedRecordUrl: string | null = null
public addRecordUrl: string | null = null
@@ -121,12 +120,9 @@ export class EditRecordComponent implements OnInit {
*/
private revalidateRecordCol(colName: string, value: any) {
const colRules = this.currentRecordValidator?.getRule(colName)
this.validateRecordCol(colRules, value).then((valid: boolean) =>
this.updateValidationState(colName, valid)
)
this.updateWarningState(colName, value)
}
/**
@@ -225,8 +221,6 @@ export class EditRecordComponent implements OnInit {
this.tryAutoPopulateNotNull(event, colName, colRules, value)
}
})
this.updateWarningState(colName, value)
})
}
@@ -243,22 +237,6 @@ export class EditRecordComponent implements OnInit {
}
}
/**
* Updates the SOFTREGEX warning columns list — the modal's equivalent of
* makeRegexWarningRenderer in the grid (see DcValidator.failsSoftRegex).
*/
private updateWarningState(colName: string, value: any): void {
const failsSoftRegex =
this.currentRecordValidator?.failsSoftRegex(colName, value) ?? false
const index = this.currentRecordWarningCols.indexOf(colName)
if (!failsSoftRegex && index > -1) {
this.currentRecordWarningCols.splice(index, 1)
} else if (failsSoftRegex && index < 0) {
this.currentRecordWarningCols.push(colName)
}
}
/**
* Auto-populates NOTNULL default value when the field is empty and has a default
*/
@@ -286,8 +264,6 @@ export class EditRecordComponent implements OnInit {
this.validateRecordCol(colRules, defaultValue).then((isValid: boolean) => {
this.updateValidationState(colName, isValid)
})
this.updateWarningState(colName, defaultValue)
}
onNextRecordClick() {
@@ -1,9 +1,7 @@
<div>
@for (state of processedStates; track state; let i = $index) {
<p class="m-0">
{{ state }}
</p>
}
<p class="m-0" *ngFor="let state of processedStates; let i = index">
{{ state }}
</p>
</div>
<div class="text-center">
<span class="spinner spinner-sm vertical-align-middle">Loading...</span>
File diff suppressed because it is too large Load Diff
@@ -1,29 +0,0 @@
.va-filter-controls {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
.va-toggle {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin: 0;
cursor: pointer;
}
.va-pending-text {
font-style: italic;
color: #8a5200;
}
}
.va-loading-text {
font-style: italic;
color: #0072a3;
}
.va-disabled-text {
font-style: italic;
opacity: 0.7;
}
+97 -562
View File
@@ -11,7 +11,7 @@ import {
ViewEncapsulation
} from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import Handsontable, { CellRange } from 'handsontable'
import Handsontable from 'handsontable'
import { Subject, Subscription } from 'rxjs'
import { sanitiseForSas } from '../shared/utils/sanitise'
import { SasStoreService } from '../services/sas-store.service'
@@ -21,6 +21,7 @@ type AOA = any[][]
import { HotTableComponent } from '@handsontable/angular-wrapper'
import { UploadFile } from '@sasjs/adapter'
import { isSpecialMissing } from '@sasjs/utils/input/validators'
import CellRange from 'handsontable/3rdparty/walkontable/src/cell/range'
import { CellValidationSource } from '../models/CellValidationSource'
import { FileUploader } from '../models/FileUploader.class'
import { FilterGroup, FilterQuery } from '../models/FilterQuery'
@@ -33,7 +34,7 @@ import {
Version
} from '../models/sas/editors-getdata.model'
import { DataFormat } from '../models/sas/common/DateFormat'
import { Approver, ExcelRule, QueryClause } from '../models/TableData'
import { Approver, ExcelRule } from '../models/TableData'
import { QueryComponent } from '../query/query.component'
import { EventService } from '../services/event.service'
import { HelperService } from '../services/helper.service'
@@ -46,15 +47,9 @@ import { DQRule } from '../shared/dc-validator/models/dq-rules.model'
import { getHotDataSchema } from '../shared/dc-validator/utils/getHotDataSchema'
import { excelRound } from '../shared/dc-validator/utils/excelRound'
import { isEmpty } from '../shared/dc-validator/utils/isEmpty'
import { parseLabelsParam } from '../shared/utils/parse-labels-param'
import { getDisplayColHeaders } from '../shared/utils/display-col-headers'
import { buildColInfoHtml } from '../shared/utils/col-info-html'
import { globals } from '../_globals'
import { UploadStaterComponent } from './components/upload-stater/upload-stater.component'
import {
DynamicCellValidation,
DynamicExtendedCellValidation
} from './models/dynamicExtendedCellValidation'
import { DynamicExtendedCellValidation } from './models/dynamicExtendedCellValidation'
import { EditRecordInputFocusedEvent } from './models/edit-record/edit-record-events'
import { EditorRestrictions } from './models/editor-restrictions.model'
import { parseTableColumns } from './utils/grid.utils'
@@ -66,8 +61,6 @@ import {
import { LicenceService } from '../services/licence.service'
import { FileUploadEncoding } from '../models/FileUploadEncoding'
import { SpreadsheetService } from '../services/spreadsheet.service'
import { VaMessagingService, VaMessage } from '../services/va-messaging.service'
import { VaFilterService } from '../services/va-filter.service'
import { UploadFileResponse } from '../models/UploadFile'
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
import { ParseResult } from '../models/ParseResult.interface'
@@ -163,11 +156,8 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
items: {
edit_row: {
name: 'Edit row',
// HOT 18's MenuItemConfig types `hidden` as a plain `() => boolean` with
// no `this` type, so an object-literal method here has `this` inferred
// as the surrounding item config unless declared explicitly.
hidden(this: Handsontable.Core) {
const hot = this
hidden() {
const hot: Handsontable.Core = this
// Hide editing actions in read-only (view) mode.
if (hot.getSettings().readOnly) return true
@@ -248,20 +238,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
redo: {
name: 'Redo',
hidden: () => this.hotTable.readOnly === true
},
// Navigates rather than just flipping `useLabels` locally, so the
// URL stays the single source of truth (shareable/refreshable
// state) — the queryParams subscription in ngOnInit picks up the
// change and re-renders colHeaders.
toggle_labels: {
name: () => (this.useLabels ? 'Show names' : 'Show labels'),
callback: () => {
this.router.navigate([], {
relativeTo: this.route,
queryParams: { labels: this.useLabels ? null : true },
queryParamsHandling: 'merge'
})
}
}
}
}
@@ -285,11 +261,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
public libTab: string | undefined
public queryFilter: any
public _query: Subscription | undefined
private _queryParams: Subscription | undefined
// `?labels=true` toggle (see parseLabelsParam) — URL is the source of
// truth, kept in sync via the queryParams subscription in ngOnInit.
public useLabels: boolean = false
public whereString: string | undefined
public clauses: any
@@ -311,44 +282,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
get embed() {
return globals.embed
}
/** True when running as a SAS Visual Analytics data-driven content object. */
get isVaEmbed() {
return globals.embed === 'va'
}
/**
* VA filter mode. Live = auto-apply each VA change; confirm = stage the change
* and wait for the user to click Apply. Backed by globals (toggled by the
* Auto-apply checkbox) so it persists across the editor reloads a filter apply
* triggers. Defaults to 'live'.
*/
get vaAutoApply(): boolean {
return globals.vaApplyMode !== 'confirm'
}
/**
* VA filter UI status (shown in both modes):
* - 'pending' : a VA change was received and not yet fetched (debouncing in
* live mode, or awaiting Apply in confirm mode);
* - 'loading' : the filter is being fetched (saveQuery), just before DC's
* native "Loading Table" takes over for the data;
* - 'idle' : nothing pending.
*/
public vaFilterStatus: 'idle' | 'pending' | 'loading' = 'idle'
/** The staged clauses/signature for the pending VA filter. */
private vaPendingClauses: QueryClause[] | null = null
private vaPendingSignature = ''
/** Removes the VA postMessage listener; set while subscribed. */
private vaUnsubscribe?: () => void
/** Guards against overlapping VA filter reloads. */
private vaApplyingFilter = false
/** Debounce timer coalescing a burst of VA messages into one apply. */
private vaDebounceTimer?: ReturnType<typeof setTimeout>
/**
* Debounce window (ms) for VA messages. Sized so a burst of rapid control
* clicks coalesces into a single apply (the last one) BEFORE any filter
* reload. Note VA itself also throttles how often it posts, so this stacks
* with that cadence.
*/
private static readonly VA_DEBOUNCE_MS = 800
public tableTrue: boolean | undefined
public saveLoading = false
public approvers: string[] = []
@@ -461,6 +394,30 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
message: string
} = { open: false, title: '', message: '' }
private confirmModalResolver: ((v: boolean) => void) | null = null
// Index maps into the positional arrays returned by getdynamiccolvals.
// The DISPLAY_VALUE column was dropped from the payload (it was never consumed
// here — the dropdown source uses RAW_VALUE and rows are matched via
// DISPLAY_INDEX), so these indices are contiguous.
public extendedCellValidationFields: {
DISPLAY_INDEX: number
EXTRA_COL_NAME: number
DISPLAY_TYPE: number
RAW_VALUE_NUM: number
RAW_VALUE_CHAR: number
FORCE_FLAG: number
} = {
DISPLAY_INDEX: 0,
EXTRA_COL_NAME: 1,
DISPLAY_TYPE: 2,
RAW_VALUE_NUM: 3,
RAW_VALUE_CHAR: 4,
FORCE_FLAG: 5
}
public cellValidationFields: {
DISPLAY_INDEX: number
RAW_VALUE: number
} = { DISPLAY_INDEX: 0, RAW_VALUE: 1 }
public disabledBasicDynamicCellValidationMap: {
row: number
@@ -472,7 +429,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
private ariaObserver: MutationObserver | undefined
private ariaCheckInterval: any | undefined
private gridResizeObserver: ResizeObserver | undefined
constructor(
private licenceService: LicenceService,
@@ -484,9 +440,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
private route: ActivatedRoute,
private sasService: SasService,
private cdf: ChangeDetectorRef,
private spreadsheetService: SpreadsheetService,
private vaMessaging: VaMessagingService,
private vaFilter: VaFilterService
private spreadsheetService: SpreadsheetService
) {
this.parseRestrictions()
this.setRestrictions()
@@ -980,40 +934,16 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
}
}
/**
* Reads the current multi-column sort config defensively. The sorting plugin
* may not be initialised yet (e.g. editTable() invoked right after load in VA
* embed mode, before the grid finished setup), in which case getSortConfig()
* throws on null internal state. There's no sort to preserve then, so [].
*/
private getCurrentSortConfigs(): any[] {
const hot = this.hotInstance
if (!hot) return []
try {
const plugin = hot.getPlugin('multiColumnSorting')
const cfg = plugin?.getSortConfig()
return Array.isArray(cfg) ? cfg : cfg ? [cfg] : []
} catch {
return []
}
}
editTable(previewEdit?: boolean, newRow?: boolean) {
this.toggleHotPlugin('contextMenu', true)
const hot = this.hotInstance
if (!hot) return
// Entering edit mode: cancel any scheduled VA filter apply so it can't fire
// mid-edit and wipe the edits. A pending filter is re-applied on the return
// to read-only (cancelEdit).
if (this.vaDebounceTimer) {
clearTimeout(this.vaDebounceTimer)
this.vaDebounceTimer = undefined
}
const columnSorting = hot.getPlugin('multiColumnSorting')
const sortConfigs = this.getCurrentSortConfigs()
const columnSortConfig = columnSorting.getSortConfig()
const sortConfigs = Array.isArray(columnSortConfig)
? columnSortConfig
: [columnSortConfig]
setTimeout(() => {
if (!previewEdit) {
@@ -1107,16 +1037,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
}
this.checkRowLimit()
// Back to read-only: apply any VA filter change that was held pending while
// editing (live mode). Confirm mode leaves it staged for the Apply button.
if (
this.isVaEmbed &&
this.vaAutoApply &&
this.vaFilterStatus === 'pending'
) {
this.applyPendingVaFilter()
}
}
/**
@@ -1363,11 +1283,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
hot.updateSettings(
{
data: this.dataSource,
colHeaders: getDisplayColHeaders(
this.headerColumns,
this.cols,
this.useLabels
),
colHeaders: this.headerColumns,
columns: this.cellValidation,
modifyColWidth: function (width: number, col: number) {
if (col === 0) {
@@ -1446,9 +1362,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
hot.removeCellMeta(rowIndex, col, 'valid')
hot.removeCellMeta(rowIndex, col, 'dupKey')
// Remove our custom class from cell metadata
// getCellMeta<M>() defaults to Record<string, unknown> in HOT 18; pin it to
// CellMeta so `.className` keeps its real string | string[] type below.
const cellMeta = hot.getCellMeta<Handsontable.CellMeta>(rowIndex, col)
const cellMeta = hot.getCellMeta(rowIndex, col)
if (cellMeta.className) {
let cleanedClassName: string
if (Array.isArray(cellMeta.className)) {
@@ -1550,7 +1464,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
for (const entry of this.cellValidationSource) {
const colSource = entry.values.map(
(el: DynamicCellValidation) => el.RAW_VALUE
(el: any) => el[this.cellValidationFields.RAW_VALUE]
)
hot.batch(() => {
@@ -1566,9 +1480,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
if (!cellSource) cellSource = []
const combinedSource = [
...new Set([...cellSource, ...colSource])
] as string[]
const combinedSource = [...new Set([...cellSource, ...colSource])]
this.currentEditRecordValidator?.updateRule(entry.col, {
source: combinedSource
@@ -1737,7 +1649,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
}
const forceValue = valuesForCol.find(
(x: DynamicExtendedCellValidation) => x.FORCED_VALUE === 1
(x: DynamicExtendedCellValidation) => x.FORCE_FLAG === 1
)
if (forceValue) {
@@ -1781,15 +1693,30 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
rowOverride ? rowOverride : cellValidationEntry.row,
cellValidationEntry.col
)
const valueIndex = (
cellValidationEntry.values.find((x) => x.RAW_VALUE === cellValue) || {}
).DISPLAY_INDEX
const valueIndex = (cellValidationEntry.values.find(
(x) => x[this.cellValidationFields.RAW_VALUE] === cellValue
) || [])[this.cellValidationFields.DISPLAY_INDEX]
return (
cellValidationEntry.extended_values?.filter(
(x) => x.DISPLAY_INDEX === valueIndex
) || []
const filteredValues = cellValidationEntry.extended_values?.filter(
(x) => x[0] === valueIndex
)
const prepObj = this.helperService.deepClone(
this.extendedCellValidationFields
)
const extendedValuesObject = []
for (const extendedValue of filteredValues || []) {
const tempObj: any = {}
for (const key of Object.keys(prepObj)) {
tempObj[key] = extendedValue[prepObj[key]]
}
extendedValuesObject.push(tempObj)
}
return extendedValuesObject
}
public checkSave() {
@@ -1833,11 +1760,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
hot.updateSettings(
{
data: this.dataSource,
colHeaders: getDisplayColHeaders(
this.headerColumns,
this.cols,
this.useLabels
),
colHeaders: this.headerColumns,
columns: this.cellValidation,
modifyColWidth: function (width: number, col: number) {
if (width > 500) return 500
@@ -2197,38 +2120,29 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
toggleHotPlugin(pluginName: string, enable: boolean) {
const hot = this.hotInstance
const contextMenuPlugin = hot.getPlugin<any>(pluginName)
hot.batch(() => {
const contextMenuPlugin = hot.getPlugin<any>(pluginName)
if (!contextMenuPlugin) {
console.warn(
'Toggle Hot Plugin failed - Plugin named: ' +
pluginName +
' - could not be found.'
)
return
}
setTimeout(() => {
// The instance may be destroyed/rebuilt within this 100ms window (e.g. a
// VA filter reload repopulates the grid). A destroyed plugin has its `hot`
// reference deleted, so enablePlugin()/disablePlugin() would throw on
// `this.hot.getSettings()`. Bail if the instance is gone or was swapped.
if (
hot.isDestroyed ||
this.hotInstance !== hot ||
!contextMenuPlugin.hot
) {
if (!contextMenuPlugin) {
console.warn(
'Toggle Hot Plugin failed - Plugin named: ' +
pluginName +
' - could not be found.'
)
return
}
if (enable) {
contextMenuPlugin.enablePlugin()
} else {
setTimeout(() => {
if (enable) {
contextMenuPlugin.enablePlugin()
return
}
contextMenuPlugin.disablePlugin()
}
}, 100)
hot.render()
}, 100)
})
}
private dynamicCellValidationDisabled(row: number, col: number) {
@@ -2306,12 +2220,10 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
if (validationSourceIndex > -1) {
let colSource = this.cellValidationSource[
validationSourceIndex
].values.map((el) => el.RAW_VALUE)
].values.map((el) => el[this.cellValidationFields.RAW_VALUE])
// `.source` (dropdown/autocomplete list) is typed unknown[] | Function here since
// getCellMeta() isn't given a type param; cast to the array branch we actually use.
const cellHadSource =
((hot.getCellMeta(row, column).source as unknown[]) || []).length < 1
(hot.getCellMeta(row, column).source || []).length < 1
const cellHasValue = cellData !== ' '
hot.batch(() => {
@@ -2348,9 +2260,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
) as string[]
}
const cellSourceCombined = [
...new Set([...cellSource, ...colSource])
] as string[]
const cellSourceCombined = [...new Set([...cellSource, ...colSource])]
hot.setCellMeta(row, column, 'source', cellSourceCombined)
@@ -2458,7 +2368,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
}
const colSource = res.adapterResponse.dynamic_values.map(
(el: DynamicCellValidation) => el.RAW_VALUE
(el: any) => el[this.cellValidationFields.RAW_VALUE]
)
this.currentEditRecordLoadings.splice(
@@ -2686,12 +2596,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
}
async ngOnInit() {
// VA data-driven content: register the postMessage listener as early as
// possible (like the SAS sample's load-time registration) so VA's initial
// data message isn't missed during the editor's getdata load. The message
// is buffered on the service and replayed once the grid is ready.
if (this.isVaEmbed) this.subscribeToVaMessages()
// Initialize hot table settings
this.updateHotTableSettings()
@@ -2702,14 +2606,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
}
)
// URL is the source of truth for the labels toggle: react to `?labels=`
// changes (including same-page navigation from the dropdown menu item)
// rather than reading it once from the route snapshot.
this._queryParams = this.route.queryParams.subscribe((params) => {
this.useLabels = parseLabelsParam(new URLSearchParams(params).toString())
this.applyDisplayColHeaders()
})
this._query = this.sasStoreService.query.subscribe((query: any) => {
if (query.libds === this.libds) {
this.whereString = query.string
@@ -2744,12 +2640,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
}
}
// VA initial load: if VA's first message already carries a filter, apply it
// BEFORE the first getdata so the initial fetch is filtered (no full-table
// load + reload). Navigates away on success; this instance aborts its load.
// Falls back to the normal unfiltered load on timeout/no-filter/failure.
if (await this.maybeDeferInitialVaLoad()) return
if (this.libds) {
this.getdataError = false
@@ -2765,51 +2655,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
}
}
/**
* VA mode, initial (unfiltered) route only: briefly wait for VA's buffered
* first message and, if it already carries a filter, save that filter and
* navigate to /editor/<table>/<FILTER_RK> so the very first getdata is
* filtered. Returns true if it navigated (caller must abort its own load),
* false to proceed with the normal unfiltered load.
*
* The filter is built directly from VA parameters (param label === DC column,
* which VA↔DC matching already requires; numeric inferred from the VA param
* dataType/value) because DC column metadata isn't available until getdata
* has run. Any failure falls back to the normal load; the post-load replay
* (with full metadata) reconciles to the same filter signature.
*/
private async maybeDeferInitialVaLoad(): Promise<boolean> {
if (!this.isVaEmbed) return false
// Only the initial, unfiltered route — never when a FILTER_RK is present.
if (typeof this.route.snapshot.params['filterId'] !== 'undefined')
return false
const libds = this.route.snapshot.params['libMem']
if (!libds) return false
// VA posts its first message before Angular boots, so it's already in the
// external buffer by now — read it directly (no polling). If it hasn't
// arrived yet, skip the optimisation and let the normal load + live message
// apply the filter.
const msg = this.vaMessaging.latestMessage()
if (!msg) return false
const clauses = this.vaFilter.buildInitialClauses(msg)
if (clauses.length === 0) return false
try {
const res: any = await this.sasStoreService.saveQuery(libds, clauses)
const id = res?.result?.[0]?.FILTER_RK
const table = res?.result?.[0]?.FILTER_TABLE
if (id === undefined || table === undefined) return false
// Seed the cross-reload signature so the post-reload replay is a no-op.
this.vaMessaging.filterSignature = this.vaFilter.signature(clauses)
await this.reloadEditorRoute('/editor/' + table + '/' + id)
return true
} catch {
return false
}
}
ngAfterViewInit() {
// Fix ARIA accessibility issues after table initialization
setTimeout(() => {
@@ -2838,26 +2683,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
}, 100)
}
/**
* Recomputes `colHeaders` from `headerColumns`/`cols` for the current
* `useLabels` state, and pushes it into a live grid via `updateSettings`
* if one is mounted (no refetch needed).
*/
private applyDisplayColHeaders() {
if (!this.hotInstance || !this.headerColumns.length) return
const colHeaders = getDisplayColHeaders(
this.headerColumns,
this.cols,
this.useLabels
)
this.hotInstance.updateSettings({ colHeaders }, false)
}
ngOnDestroy() {
this._queryParams?.unsubscribe()
// Clean up the MutationObserver
if (this.ariaObserver) {
this.ariaObserver.disconnect()
@@ -2869,43 +2695,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
clearInterval(this.ariaCheckInterval)
this.ariaCheckInterval = undefined
}
// Stop observing the grid container for resizes
if (this.gridResizeObserver) {
this.gridResizeObserver.disconnect()
this.gridResizeObserver = undefined
}
// Cancel any pending debounced VA apply
if (this.vaDebounceTimer) {
clearTimeout(this.vaDebounceTimer)
this.vaDebounceTimer = undefined
}
// Remove the VA postMessage listener
if (this.vaUnsubscribe) {
this.vaUnsubscribe()
this.vaUnsubscribe = undefined
}
}
/**
* Re-run height+render when the grid container settles.
*/
private observeGridResize() {
const el = this.hotInstance?.rootElement?.parentElement
if (!el) return
this.gridResizeObserver?.disconnect()
this.gridResizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
const hot = this.hotInstance
if (!hot || hot.isDestroyed) return
hot.updateSettings({ height: this.hotTable.height }, false)
hot.render()
})
})
this.gridResizeObserver.observe(el)
}
/**
@@ -3055,18 +2844,13 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
}
}
initSetup(response: EditorsGetDataServiceResponse, attempt = 0) {
if (this.getdataError) return
if (!response || !response.data) return
initSetup(response: EditorsGetDataServiceResponse) {
this.hotInstance = this.hotTableComponent!.hotInstance!
this.hotInstance = this.hotTableComponent?.hotInstance!
if (!this.hotInstance) {
// Retry init, don't permanently abort
if (attempt < 60) {
setTimeout(() => this.initSetup(response, attempt + 1), 50)
}
return
}
if (this.getdataError) return
if (!response) return
if (!response.data) return
if (!this.hotInstance) return
this.cols = response.data.cols
this.dsmeta = response.data.dsmeta
@@ -3175,11 +2959,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
hot.updateSettings(
{
data: this.dataSource,
colHeaders: getDisplayColHeaders(
this.headerColumns,
this.cols,
this.useLabels
),
colHeaders: this.headerColumns,
columns: this.cellValidation,
height: this.hotTable.height,
formulas: this.hotTable.formulas,
@@ -3203,7 +2983,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
manualColumnResize: true,
filters: false,
manualRowResize: true,
viewportRowRenderingOffset: 100,
viewportRowRenderingOffset: 50,
// show a bar on the left to enable users to select an entire row
rowHeaders: (index: number) => {
return ' '
@@ -3239,22 +3019,20 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
itemValue: string
) => {
const elem = document.createElement('span')
let colName = ''
let colInfo: DataFormat | undefined
let textInfo = 'No info found'
if (this.hotInstance) {
// getSelected() is typed number[][] in HOT 18 (was a 4-tuple
// array before); loosen the annotation to match.
const hotSelected: number[][] =
const hotSelected: [number, number, number, number][] =
this.hotInstance.getSelected() || []
const selectedCol: number = hotSelected
? hotSelected[0][1]
: -1
colName = this.hotInstance?.colToProp(selectedCol) as string
const colName = this.hotInstance?.colToProp(selectedCol)
colInfo = this.$dataFormats?.vars[colName]
textInfo = buildColInfoHtml(colName, colInfo)
if (colInfo)
textInfo = `LABEL: ${colInfo?.label}<br>TYPE: ${colInfo?.type}<br>LENGTH: ${colInfo?.length}<br>FORMAT: ${colInfo?.format}`
}
elem.innerHTML = textInfo
@@ -3455,14 +3233,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
if (cellMeta && cellMeta.filter === false)
hot.setCellMeta(row, col, 'filter', true)
// Toggling Delete? changes every other cell's validation exemption
// in this row (see DcValidator.setDefaultValidator) — re-validate so
// invalid highlights clear/reappear immediately instead of only at
// submit time.
if (colProp === '_____DELETE__THIS__RECORD_____') {
hot.validateRows([row], () => hot.render())
}
this.executeDynamicCellValidationIfApplicable(colProp, col, row)
}
})
@@ -3500,6 +3270,8 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
)
hot.addHook('afterRender', (isForced: boolean) => {
this.eventService.dispatchEvent('resize')
// Fix ARIA accessibility issues after each render
this.fixAriaAccessibility()
})
@@ -3630,246 +3402,9 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
hot.render()
this.observeGridResize()
// Fix ARIA accessibility issues after table initialization
setTimeout(() => {
this.fixAriaAccessibility()
}, 500)
// SAS Visual Analytics data-driven content mode: open in read-only view (NOT
// immediate edit — the user clicks Edit to make changes), re-apply any column
// visibility chosen by VA before this (filter) reload, and start receiving VA
// messages over the postMessage interface.
if (this.isVaEmbed) {
if (this.vaMessaging.visibleColumns) {
this.applyVaColumnVisibility(new Set(this.vaMessaging.visibleColumns))
if (this.hotInstance) this.hotInstance.render()
}
// Listener is already registered early (ngOnInit) so VA's initial post is
// not missed; this is a guarded no-op if so. Now that the grid is ready,
// replay the buffered latest message to apply any filter/visibility that
// arrived during load.
this.subscribeToVaMessages()
// Reconcile to the true latest message after a reload — covers a message
// that arrived during the reload gap (caught by va-early, missed by the
// app listener). Signature dedup makes this a no-op if nothing changed.
this.handleVaMessage()
}
}
/**
* Subscribes to the SAS VA data-driven content postMessage channel. VA pushes
* a fresh message on every selection / parameter change. Registered early (in
* ngOnInit) so the initial message isn't missed; messages are buffered on the
* service and applied via a debounce so a burst of control clicks collapses
* into a single reload on the latest message.
*/
private subscribeToVaMessages() {
if (this.vaUnsubscribe) return
this.vaUnsubscribe = this.vaMessaging.onData(() => this.handleVaMessage())
}
/**
* Handles one VA message immediately (NOT debounced): updates column
* visibility and decides the filter. The cheap work (visibility, building the
* filter clauses, deciding pending) runs per message so the "pending"
* indicator can show the instant VA posts; only the expensive APPLY (saveQuery
* + reload) is debounced (live mode) via scheduleVaFilterApply.
*
* Filter data always comes from DC's getdata; VA only drives (a) column
* visibility and (b) the active filter (its params -> DC clauses, applied by
* reloading at /editor/<table>/<FILTER_RK>).
*/
private handleVaMessage() {
const msg = this.vaMessaging.latestMessage()
if (!msg || !this.libds || !this.cellValidation) return
// Grid not ready yet (cellValidation only has the delete column, or none):
// the label->var map would be empty, so every param would fail to map and
// produce an empty filter that wrongly CLEARS the active one — the infinite
// reload loop. Bail; the load-complete replay re-runs once metadata is in.
if (this.cellValidation.length <= 1) return
const labelToVar = this.vaFilter.buildLabelToVarMap(
this.cellValidation,
this.columnHeader
)
// Columns present in the message -> show these, hide the rest. A message
// with no data columns carries no column info, so leave visibility as-is
// rather than hiding everything.
const dataColumns = this.vaMessaging.dataColumns(msg)
let visibilityChanged = false
if (dataColumns.length > 0) {
const matchedVars = new Set<string>()
for (const { column } of dataColumns) {
const key = (column.label ?? column.name ?? '')
.toString()
.trim()
.toLowerCase()
const varName = labelToVar.get(key)
if (varName) matchedVars.add(varName)
}
this.vaMessaging.visibleColumns = Array.from(matchedVars)
visibilityChanged = this.applyVaColumnVisibility(matchedVars)
}
if (visibilityChanged && this.hotInstance) this.hotInstance.render()
this.updateVaFilterText(msg)
const clauses = this.vaFilter.buildClauses(msg, labelToVar, this.cols)
const signature = this.vaFilter.signature(clauses)
if (signature === this.vaMessaging.filterSignature) {
// Selection matches the applied filter — nothing pending. (Don't clear a
// 'loading' status; that belongs to an apply already in flight.)
if (this.vaFilterStatus === 'pending') this.vaFilterStatus = 'idle'
this.vaPendingClauses = null
return
}
// A real change arrived -> pending (shown in both modes, immediately).
this.vaPendingClauses = clauses
this.vaPendingSignature = signature
this.vaFilterStatus = 'pending'
// Filtering repopulates the grid, so it must never run while the user is
// editing (it would wipe their in-progress edits). While in edit mode we
// only surface the pending indicator; the filter is applied on return to
// read-only (see cancelEdit). Live mode auto-advances after the debounce
// settles; confirm waits for the Apply button.
if (this.vaAutoApply && this.hotTable.readOnly) this.scheduleVaFilterApply()
}
/** Live mode: debounce a burst of VA changes into a single apply of the latest. */
private scheduleVaFilterApply() {
if (this.vaDebounceTimer) clearTimeout(this.vaDebounceTimer)
this.vaDebounceTimer = setTimeout(() => {
this.vaDebounceTimer = undefined
this.applyPendingVaFilter()
}, EditorComponent.VA_DEBOUNCE_MS)
}
/**
* Saves the VA-derived filter through DC's validatefilter flow and reloads the
* editor at /editor/<table>/<FILTER_RK> (preserving ?embed=va) so the filter
* key is visible in the URL. Empty clauses clear the filter.
*/
private async applyVaFilter(clauses: QueryClause[], signature: string) {
// A previous filter is still saving/reloading: drop this call WITHOUT
// advancing the signature. The reloaded editor's load-complete replays the
// latest buffered message, which re-evaluates against the still-accurate
// last-applied signature and applies whatever the newest selection is.
if (this.vaApplyingFilter) return
this.vaApplyingFilter = true
// Now actually fetching: surface the "loading filter" status (until DC's
// native "Loading Table" takes over after the reload navigates).
this.vaFilterStatus = 'loading'
// Record the signature only now that we're actually applying it.
this.vaMessaging.filterSignature = signature
try {
if (clauses.length === 0) {
// Already unfiltered (no FILTER_RK in route)? Do nothing — reloading
// would be a pointless round-trip and re-trigger the drain/replay cycle.
// Only reload to clear a filter that is currently applied.
if (typeof this.filter_pk === 'undefined') {
this.vaFilterStatus = 'idle'
return
}
await this.reloadEditorRoute('/editor/' + this.libds)
return
}
const res: any = await this.sasStoreService.saveQuery(
this.libds!,
clauses
)
const id = res?.result?.[0]?.FILTER_RK
const table = res?.result?.[0]?.FILTER_TABLE
if (id === undefined || table === undefined) {
this.vaFilterStatus = 'idle'
return
}
await this.reloadEditorRoute('/editor/' + table + '/' + id)
} catch {
// Keep the current view on failure; the next message can retry.
this.vaFilterStatus = 'idle'
} finally {
this.vaApplyingFilter = false
}
}
/** Apply the staged VA filter (the debounce in live mode, or the Apply button). */
public applyPendingVaFilter(): void {
if (!this.vaPendingClauses) return
// Never apply while editing — the reload would wipe in-progress edits. The
// pending filter stays staged and is applied when the user leaves edit mode.
if (!this.hotTable.readOnly) return
const clauses = this.vaPendingClauses
const signature = this.vaPendingSignature
this.vaPendingClauses = null
void this.applyVaFilter(clauses, signature)
}
/**
* Toggles between live (auto-apply) and confirm filter modes. The choice is
* stored on globals so it survives editor reloads. Switching to live with a
* change still pending applies it immediately.
*/
public toggleVaAutoApply(): void {
globals.vaApplyMode = this.vaAutoApply ? 'confirm' : 'live'
if (this.vaAutoApply && this.vaFilterStatus === 'pending') {
this.applyPendingVaFilter()
}
}
/** Forces a full editor reload of `target`, preserving the embed query param. */
private async reloadEditorRoute(target: string) {
await this.router.navigate(['/'], {
skipLocationChange: true,
queryParamsHandling: 'preserve'
})
await this.router.navigate([target], { queryParamsHandling: 'preserve' })
}
/**
* Shows the DC data columns present in the latest VA message and hides the
* rest (primary-key and validator-hidden columns are always preserved). VA
* parameters can add/remove columns between pushes.
*/
private applyVaColumnVisibility(matchedVars: Set<string>): boolean {
const hot = this.hotInstance
if (!hot) return false
const plugin: any = hot.getPlugin('hiddenColumns')
if (!plugin) return false
const validatorHidden: number[] = this.dcValidator?.getHiddenColumns() || []
const toHide: number[] = []
const toShow: number[] = []
for (let i = 1; i < this.cellValidation.length; i++) {
const varName = this.cellValidation[i]?.data
if (!varName || this.isColPk(varName)) continue
if (validatorHidden.includes(i)) continue
if (matchedVars.has(varName)) {
if (plugin.isHidden(i)) toShow.push(i)
} else if (!plugin.isHidden(i)) {
toHide.push(i)
}
}
if (toShow.length) plugin.showColumns(toShow)
if (toHide.length) plugin.hideColumns(toHide)
return toShow.length > 0 || toHide.length > 0
}
/** Logs the active VA filter summary (kept out of the embedded grid UI). */
private updateVaFilterText(msg: VaMessage) {
const summary = (msg.parameters || [])
.filter((p) => p && p.name !== undefined && p.value !== undefined)
.map((p) => `${p.label ?? p.name}=${p.value}`)
.join(', ')
// eslint-disable-next-line no-console
console.log('VA filters active:', summary)
}
}
@@ -1,16 +1,6 @@
/**
* Model for a row of `dynamic_values` returned by the getdynamiccolvals
* service (the dropdown source for the edited cell itself).
*/
export interface DynamicCellValidation {
DISPLAY_INDEX: number
RAW_VALUE: string | number
}
/**
* Model for a row of `dynamic_extended_values` returned by the
* getdynamiccolvals service (dropdown values for _other_ cells in the _same_
* row, mapped via DISPLAY_INDEX).
* Model for the dynamic cell validation in the editor
* (sending whole row to the backend service and recieving data for the cell dropdown)
*/
export interface DynamicExtendedCellValidation {
DISPLAY_INDEX: number
@@ -18,5 +8,5 @@ export interface DynamicExtendedCellValidation {
EXTRA_COL_NAME: string
RAW_VALUE_CHAR: string
RAW_VALUE_NUM: number
FORCED_VALUE: number
FORCE_FLAG: number
}
-16
View File
@@ -18,22 +18,6 @@ export const dateToTime = (date: Date) => {
return timeStr
}
/**
* Normalises a 24-hour time STRING to zero-padded `HH:mm:ss`.
*
* The `intl-time` cell type (validator + native `<input type=time>` editor) is
* strict and rejects `H:mm[:ss]`, so such a value paints red and the editor
* opens empty. Pad the hour and default the seconds so the value validates.
*
* Non time-like input is returned unchanged.
*/
export const padTimeString = (value: string): string => {
const match = value.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/)
if (!match) return value
const [, hours, minutes, seconds] = match
return `${hours.padStart(2, '0')}:${minutes}:${seconds ?? '00'}`
}
/**
* Converts date object to the YYYY-MM-DD
*/
@@ -1,131 +0,0 @@
import Handsontable from 'handsontable'
import { makeRegexWarningRenderer } from './regex-warning-renderer'
describe('makeRegexWarningRenderer', () => {
const buildHot = (data: any[]) => {
const container = document.createElement('div')
document.body.appendChild(container)
const hot = new Handsontable(container, {
data,
columns: [
{ data: 'val', renderer: makeRegexWarningRenderer('^[A-Z]{3}$') },
{ data: '_____DELETE__THIS__RECORD_____' }
],
licenseKey: 'non-commercial-and-evaluation'
})
hot.render()
return { hot, container }
}
it('adds dc-warning-cell when the value fails the pattern', () => {
const { hot, container } = buildHot([
{ val: 'abc', _____DELETE__THIS__RECORD_____: 'No' }
])
const td = hot.getCell(0, 0)
expect(td?.classList.contains('dc-warning-cell')).toBeTrue()
hot.destroy()
container.remove()
})
it('does not add dc-warning-cell when the value matches the pattern', () => {
const { hot, container } = buildHot([
{ val: 'ABC', _____DELETE__THIS__RECORD_____: 'No' }
])
const td = hot.getCell(0, 0)
expect(td?.classList.contains('dc-warning-cell')).toBeFalse()
hot.destroy()
container.remove()
})
it('suppresses the warning on a row marked for delete, even though the value fails the pattern', () => {
const { hot, container } = buildHot([
{ val: 'abc', _____DELETE__THIS__RECORD_____: 'Yes' }
])
const td = hot.getCell(0, 0)
expect(td?.classList.contains('dc-warning-cell')).toBeFalse()
hot.destroy()
container.remove()
})
it('does not add dc-warning-cell for a SAS special missing value', () => {
const { hot, container } = buildHot([
{ val: '.a', _____DELETE__THIS__RECORD_____: 'No' }
])
const td = hot.getCell(0, 0)
expect(td?.classList.contains('dc-warning-cell')).toBeFalse()
hot.destroy()
container.remove()
})
it('does not throw and never warns on a malformed pattern', () => {
const container = document.createElement('div')
document.body.appendChild(container)
const hot = new Handsontable(container, {
data: [{ val: 'abc', _____DELETE__THIS__RECORD_____: 'No' }],
columns: [
{ data: 'val', renderer: makeRegexWarningRenderer('[unterminated') },
{ data: '_____DELETE__THIS__RECORD_____' }
],
licenseKey: 'non-commercial-and-evaluation'
})
expect(() => hot.render()).not.toThrow()
const td = hot.getCell(0, 0)
expect(td?.classList.contains('dc-warning-cell')).toBeFalse()
hot.destroy()
container.remove()
})
it('handles a PRX-delimited pattern with a case-insensitive flag, as authored for prxparse', () => {
const container = document.createElement('div')
document.body.appendChild(container)
const hot = new Handsontable(container, {
data: [
{ val: 'this is dummy data', _____DELETE__THIS__RECORD_____: 'No' },
{ val: 'THE WIND WAS BLOWING', _____DELETE__THIS__RECORD_____: 'No' },
{ val: 'nothing relevant here', _____DELETE__THIS__RECORD_____: 'No' }
],
columns: [
{
data: 'val',
renderer: makeRegexWarningRenderer('/\\b(the|data)\\b/i')
},
{ data: '_____DELETE__THIS__RECORD_____' }
],
licenseKey: 'non-commercial-and-evaluation'
})
hot.render()
expect(hot.getCell(0, 0)?.classList.contains('dc-warning-cell')).toBeFalse()
expect(hot.getCell(1, 0)?.classList.contains('dc-warning-cell')).toBeFalse()
expect(hot.getCell(2, 0)?.classList.contains('dc-warning-cell')).toBeTrue()
hot.destroy()
container.remove()
})
it('is display-only — the stored value is untouched', () => {
const { hot, container } = buildHot([
{ val: 'abc', _____DELETE__THIS__RECORD_____: 'No' }
])
expect(hot.getDataAtCell(0, 0)).toEqual('abc')
hot.destroy()
container.remove()
})
})
@@ -1,54 +0,0 @@
import Handsontable from 'handsontable'
import { isRegexRuleExempt } from '../../shared/dc-validator/utils/isRegexRuleExempt'
import { parseRegexRule } from '../../shared/dc-validator/utils/parseRegexRule'
/**
* Builds a display-only HOT renderer for SOFTREGEX: a value that fails the
* pattern gets a yellow `dc-warning-cell` class (styles.scss) instead of
* blocking submission — SOFTREGEX must never return false from the cell
* validator (that would paint htInvalid and block submit), so the warning
* lives purely at the render layer, same split as makeNumberFormatRenderer.
*
* Suppressed on rows marked for delete (_____DELETE__THIS__RECORD_____ =
* 'Yes') — a warning about data about to be removed is just noise.
*
* Falls back to no warning ever showing when the pattern itself is
* malformed, rather than breaking the cell.
*/
export const makeRegexWarningRenderer = (pattern?: string) => {
let regex: RegExp | null = null
try {
if (pattern) regex = parseRegexRule(pattern)
} catch (e) {
console.warn(`SOFTREGEX - invalid pattern, warning disabled: ${pattern}`)
regex = null
}
const baseRenderer = Handsontable.renderers.getRenderer('text')
return (
instance: any,
td: any,
row: number,
col: number,
prop: string | number,
value: any,
cellProperties: any
) => {
baseRenderer(instance, td, row, col, prop, value, cellProperties)
const markedForDelete =
instance.getDataAtRowProp(row, '_____DELETE__THIS__RECORD_____') === 'Yes'
const failsPattern =
!!regex && !isRegexRuleExempt(value) && !regex.test(value.toString())
if (failsPattern && !markedForDelete) {
td.classList.add('dc-warning-cell')
td.title = 'Value does not match the expected pattern'
}
return td
}
}
+130 -153
View File
@@ -1,167 +1,144 @@
<app-sidebar class="sidebar-height">
<clr-tree>
@if (groups) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchLibTreeInput
placeholder="Filter by Groups"
name="input"
[(ngModel)]="groupSearch"
(keyup)="groupListOnFilter()"
autocomplete="off"
/>
@if (searchLibTreeInput.value.length < 1) {
<clr-icon shape="search"></clr-icon>
}
@if (searchLibTreeInput.value.length > 0) {
<clr-icon
(click)="groupSearch = ''; groupListOnFilter()"
shape="times"
></clr-icon>
}
</div>
</clr-tree-node>
}
<clr-tree-node *ngIf="groups" class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchLibTreeInput
placeholder="Filter by Groups"
name="input"
[(ngModel)]="groupSearch"
(keyup)="groupListOnFilter()"
autocomplete="off"
/>
<clr-icon
*ngIf="searchLibTreeInput.value.length < 1"
shape="search"
></clr-icon>
<clr-icon
*ngIf="searchLibTreeInput.value.length > 0"
(click)="groupSearch = ''; groupListOnFilter()"
shape="times"
></clr-icon>
</div>
</clr-tree-node>
@for (group of groups; track group) {
@if (!group['hidden']) {
<clr-tree-node
(click)="groupOnClick(group)"
[class.active]="group.GROUPURI === groupUri"
>
<p class="m-0 cursor-pointer list-padding">
<clr-icon shape="users"></clr-icon>
{{ group.GROUPNAME }}
</p>
</clr-tree-node>
}
}
<ng-container *ngFor="let group of groups">
<clr-tree-node
(click)="groupOnClick(group)"
*ngIf="!group['hidden']"
[class.active]="group.GROUPURI === groupUri"
>
<p class="m-0 cursor-pointer list-padding">
<clr-icon shape="users"></clr-icon>
{{ group.GROUPNAME }}
</p>
</clr-tree-node>
</ng-container>
</clr-tree>
</app-sidebar>
<div class="content-area">
@if (loading) {
<div class="loadingSpinner">
<span class="spinner"> Loading... </span>
<div *ngIf="loading" class="loadingSpinner">
<span class="spinner"> Loading... </span>
</div>
<div *ngIf="groupMembers && !loading">
<div *ngIf="serverType !== ServerType.SasViya" class="clr-row">
<div class="clr-col-8">
<table class="table group-info">
<tbody>
<tr>
<td class="left">
<p class="group-info-text">
<b>{{ groupName }}</b>
</p>
</td>
</tr>
<tr>
<td class="left">
<i>{{ groupDesc || 'no description' }}</i>
</td>
</tr>
</tbody>
</table>
</div>
</div>
}
@if (groupMembers && !loading) {
<div>
@if (serverType !== ServerType.SasViya) {
<div class="clr-row">
<div class="clr-col-8">
<table class="table group-info">
<tbody>
<tr>
<td class="left">
<p class="group-info-text">
<b>{{ groupName }}</b>
</p>
</td>
</tr>
<tr>
<td class="left">
<i>{{ groupDesc || 'no description' }}</i>
</td>
</tr>
</tbody>
</table>
<div class="clr-row">
<div class="clr-col-8">
<div class="card group-data">
<div *ngIf="serverType !== ServerType.SasViya">
<h3>MEMBERS ({{ groupMemberCount }})</h3>
<h5 *ngIf="groupMemberCount == 0">No Members Present</h5>
<div class="table-container">
<table *ngIf="groupMemberCount != 0" class="table member-table">
<thead>
<tr>
<ng-container *ngIf="serverType === ServerType.Sas9">
<td class="width-25"><b>NAME</b></td>
<td class="width-25"><b>EMAIL</b></td>
<td class="width-25"><b>CREATED</b></td>
<td class=""><b>UPDATED</b></td>
</ng-container>
<ng-container *ngIf="serverType === ServerType.Sasjs">
<td class="width-25"><b>ID</b></td>
<td class="width-25"><b>DISPLAY NAME</b></td>
<td class="width-25"><b>USER NAME</b></td>
</ng-container>
</tr>
</thead>
<tbody>
<tr
[routerLink]="
'/view/usernav/users/' + (member.URIMEM || member.ID)
"
*ngFor="let member of groupMembers"
>
<ng-container *ngIf="serverType === ServerType.Sas9">
<td class="">{{ member.MEMBERNAME }}</td>
<td class="">{{ member.EMAIL }}</td>
<td class="">{{ member.MEMBERCREATED }}</td>
<td class="">{{ member.MEMBERUPDATED }}</td>
</ng-container>
<ng-container *ngIf="serverType === ServerType.Sasjs">
<td class="">{{ member.ID }}</td>
<td class="">{{ member.DISPLAYNAME }}</td>
<td class="">{{ member.USERNAME }}</td>
</ng-container>
</tr>
</tbody>
</table>
</div>
</div>
</div>
}
<div class="clr-row">
<div class="clr-col-8">
<div class="card group-data">
@if (serverType !== ServerType.SasViya) {
<div>
<h3>MEMBERS ({{ groupMemberCount }})</h3>
@if (groupMemberCount == 0) {
<h5>No Members Present</h5>
}
<div class="table-container">
@if (groupMemberCount != 0) {
<table class="table member-table">
<thead>
<tr>
@if (serverType === ServerType.Sas9) {
<td class="width-25"><b>NAME</b></td>
<td class="width-25"><b>EMAIL</b></td>
<td class="width-25"><b>CREATED</b></td>
<td class=""><b>UPDATED</b></td>
}
@if (serverType === ServerType.Sasjs) {
<td class="width-25"><b>ID</b></td>
<td class="width-25"><b>DISPLAY NAME</b></td>
<td class="width-25"><b>USER NAME</b></td>
}
</tr>
</thead>
<tbody>
@for (member of groupMembers; track member) {
<tr
[routerLink]="
'/view/usernav/users/' +
(member.URIMEM || member.ID)
"
>
@if (serverType === ServerType.Sas9) {
<td class="">{{ member.MEMBERNAME }}</td>
<td class="">{{ member.EMAIL }}</td>
<td class="">{{ member.MEMBERCREATED }}</td>
<td class="">{{ member.MEMBERUPDATED }}</td>
}
@if (serverType === ServerType.Sasjs) {
<td class="">{{ member.ID }}</td>
<td class="">{{ member.DISPLAYNAME }}</td>
<td class="">{{ member.USERNAME }}</td>
}
</tr>
}
</tbody>
</table>
}
</div>
</div>
}
@if (serverType === ServerType.SasViya) {
<div>
<h3>{{ groupName }}</h3>
@if (groupMemberCount == 0) {
<h5>No Members Present</h5>
}
<div class="table-container">
@if (groupMemberCount != 0) {
<table class="table member-table">
<thead>
<tr>
<td>
<b>MEMBERS ({{ groupMemberCount }})</b>
</td>
</tr>
</thead>
<tbody>
@for (member of groupMembers; track member) {
<tr
[routerLink]="
'/view/usernav/users/' + member.MEMBERID
"
>
<td class="">{{ member.MEMBERNAME }}</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
}
<br />
<div *ngIf="serverType === ServerType.SasViya">
<h3>{{ groupName }}</h3>
<h5 *ngIf="groupMemberCount == 0">No Members Present</h5>
<div class="table-container">
<table *ngIf="groupMemberCount != 0" class="table member-table">
<thead>
<tr>
<td>
<b>MEMBERS ({{ groupMemberCount }})</b>
</td>
</tr>
</thead>
<tbody>
<tr
[routerLink]="'/view/usernav/users/' + member.MEMBERID"
*ngFor="let member of groupMembers"
>
<td class="">{{ member.MEMBERNAME }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<br />
</div>
</div>
</div>
}
</div>
</div>
+145 -144
View File
@@ -1,158 +1,159 @@
<app-sidebar>
<clr-tree>
@if (treeNodeLibraries?.length! > 0) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
clrInput
appStealFocus
#searchLibTreeInput
placeholder="Libraries"
name="input"
[(ngModel)]="librariesSearch"
(keyup)="libraryOnFilter()"
autocomplete="off"
/>
@if (searchLibTreeInput.value.length < 1) {
<clr-icon shape="search" aria-hidden="true"></clr-icon>
}
@if (searchLibTreeInput.value.length > 0) {
<clr-icon
(click)="librariesSearch = ''; libraryOnFilter()"
shape="times"
aria-label="Clear libraries search"
></clr-icon>
}
</div>
</clr-tree-node>
}
<clr-tree-node *ngIf="treeNodeLibraries?.length! > 0" class="search-node">
<div class="tree-search-wrapper">
<input
clrInput
appStealFocus
#searchLibTreeInput
placeholder="Libraries"
name="input"
[(ngModel)]="librariesSearch"
(keyup)="libraryOnFilter()"
autocomplete="off"
/>
@for (library of treeNodeLibraries; track library) {
@if (!library['hidden']) {
<clr-tree-node
(click)="treeNodeClicked($event, library); lib = library.LIBRARYREF"
[(clrExpanded)]="library['expanded']"
[clrLoading]="library['loadingTables'] && !library.tables"
[class.clr-expanded]="library['expanded']"
<clr-icon
*ngIf="searchLibTreeInput.value.length < 1"
shape="search"
aria-hidden="true"
></clr-icon>
<clr-icon
*ngIf="searchLibTreeInput.value.length > 0"
(click)="librariesSearch = ''; libraryOnFilter()"
shape="times"
aria-label="Clear libraries search"
></clr-icon>
</div>
</clr-tree-node>
<ng-container *ngFor="let library of treeNodeLibraries">
<clr-tree-node
(click)="treeNodeClicked($event, library); lib = library.LIBRARYREF"
*ngIf="!library['hidden']"
[(clrExpanded)]="library['expanded']"
[clrLoading]="library['loadingTables'] && !library.tables"
[class.clr-expanded]="library['expanded']"
>
<p
(click)="
lib = library.LIBRARYREF;
libraryOnClick(library.LIBRARYREF, library)
"
class="m-0 cursor-pointer"
>
<p
(click)="
lib = library.LIBRARYREF;
libraryOnClick(library.LIBRARYREF, library)
"
class="m-0 cursor-pointer"
>
<clr-icon shape="rack-server" aria-hidden="true"></clr-icon>
{{ library.LIBRARYREF }}
</p>
@if (library['tables']) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchTreeInput
placeholder="Tables"
name="input"
[(ngModel)]="library['searchString']"
(keyup)="treeOnFilter(library, 'tables')"
autocomplete="off"
/>
@if (searchTreeInput.value.length < 1) {
<clr-icon shape="search" aria-hidden="true"></clr-icon>
}
@if (searchTreeInput.value.length > 0) {
<clr-icon
(click)="
searchTreeInput.value = '';
library['searchString'] = '';
treeOnFilter(library, 'tables')
"
shape="times"
aria-label="Clear tables search"
></clr-icon>
}
</div>
</clr-tree-node>
}
@for (
libTable of library['tables'];
track libTable;
let index = $index
) {
<clr-tree-node>
<clr-tooltip
*ngVar="
index + 1 >
licenceState.value.tables_in_library_limit as tableLocked
"
>
<button
clrTooltipTrigger
(click)="!tableLocked ? onTableClick(libTable, library) : ''"
class="clr-treenode-link"
[class.dc-locked-control]="tableLocked"
[class.active]="libTabActive(library.LIBRARYREF, libTable)"
>
@switch (libTable.includes('-FC')) {
@case (true) {
<clr-icon shape="bolt" aria-hidden="true"></clr-icon>
}
@case (false) {
<clr-icon shape="table" aria-hidden="true"></clr-icon>
}
}
{{ libTable.replace('-FC', '') }}
</button>
@if (tableLocked) {
<clr-tooltip-content
clrPosition="bottom-right"
clrSize="lg"
*clrIfOpen
>
<span>
To unlock all tables, contact
support&#64;datacontroller.io
</span>
</clr-tooltip-content>
}
</clr-tooltip>
</clr-tree-node>
}
<clr-icon shape="rack-server" aria-hidden="true"></clr-icon>
{{ library.LIBRARYREF }}
</p>
<clr-tree-node *ngIf="library['tables']" class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchTreeInput
placeholder="Tables"
name="input"
[(ngModel)]="library['searchString']"
(keyup)="treeOnFilter(library, 'tables')"
autocomplete="off"
/>
<clr-icon
*ngIf="searchTreeInput.value.length < 1"
shape="search"
aria-hidden="true"
></clr-icon>
<clr-icon
*ngIf="searchTreeInput.value.length > 0"
(click)="
searchTreeInput.value = '';
library['searchString'] = '';
treeOnFilter(library, 'tables')
"
shape="times"
aria-label="Clear tables search"
></clr-icon>
</div>
</clr-tree-node>
}
}
<clr-tree-node
*ngFor="let libTable of library['tables']; let index = index"
>
<clr-tooltip
*ngVar="
index + 1 >
licenceState.value.tables_in_library_limit as tableLocked
"
>
<button
clrTooltipTrigger
(click)="!tableLocked ? onTableClick(libTable, library) : ''"
class="clr-treenode-link"
[class.dc-locked-control]="tableLocked"
[class.active]="libTabActive(library.LIBRARYREF, libTable)"
>
<ng-container [ngSwitch]="libTable.includes('-FC')">
<clr-icon
*ngSwitchCase="true"
shape="bolt"
aria-hidden="true"
></clr-icon>
<clr-icon
*ngSwitchCase="false"
shape="table"
aria-hidden="true"
></clr-icon>
</ng-container>
{{ libTable.replace('-FC', '') }}
</button>
<ng-container *ngIf="tableLocked">
<clr-tooltip-content
clrPosition="bottom-right"
clrSize="lg"
*clrIfOpen
>
<span>
To unlock all tables, contact support&#64;datacontroller.io
</span>
</clr-tooltip-content>
</ng-container>
</clr-tooltip>
</clr-tree-node>
</clr-tree-node>
</ng-container>
</clr-tree>
</app-sidebar>
<main class="content-area">
<div class="card-block">
@if (loading) {
<div class="spinner-wrapper-fullpage">
<div class="loadingSpinner">
<span class="spinner"> Loading... </span>
</div>
<div *ngIf="loading" class="spinner-wrapper-fullpage">
<div class="loadingSpinner">
<span class="spinner"> Loading... </span>
</div>
}
</div>
@if (!loading) {
<div class="no-table-selected">
<img
src="images/select-table.png"
class="select-table-icon"
alt="select table icon"
/>
@if (treeNodeLibraries?.length! > 0) {
<p class="text-center color-gray mt-10" cds-text="section">
Please select a table
</p>
}
@if (treeNodeLibraries?.length! < 1) {
<p class="text-center color-gray mt-10" cds-text="section">
No Editable Tables Configured
</p>
}
</div>
}
<div *ngIf="!loading" class="no-table-selected">
<img
src="images/select-table.png"
class="select-table-icon"
alt="select table icon"
/>
<p
*ngIf="treeNodeLibraries?.length! > 0"
class="text-center color-gray mt-10"
cds-text="section"
>
Please select a table
</p>
<p
*ngIf="treeNodeLibraries?.length! < 1"
class="text-center color-gray mt-10"
cds-text="section"
>
No Editable Tables Configured
</p>
</div>
</div>
</main>
+244 -253
View File
@@ -1,176 +1,168 @@
<app-sidebar (scrolledToBottom)="loadMoreLibraries()">
<clr-tree>
@if (libraryList) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchLibTreeInput
placeholder="Libraries"
name="input"
[(ngModel)]="librariesSearch"
(keyup)="libraryOnFilter()"
autocomplete="off"
/>
@if (searchLibTreeInput.value.length < 1) {
<clr-icon shape="search"></clr-icon>
}
@if (searchLibTreeInput.value.length > 0) {
<clr-tree-node *ngIf="libraryList" class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchLibTreeInput
placeholder="Libraries"
name="input"
[(ngModel)]="librariesSearch"
(keyup)="libraryOnFilter()"
autocomplete="off"
/>
<clr-icon
*ngIf="searchLibTreeInput.value.length < 1"
shape="search"
></clr-icon>
<clr-icon
*ngIf="searchLibTreeInput.value.length > 0"
(click)="librariesSearch = ''; libraryOnFilter()"
shape="times"
></clr-icon>
</div>
</clr-tree-node>
<ng-container *ngFor="let library of libraryList">
<clr-tree-node
(click)="treeNodeClicked($event, library, libraryList)"
*ngIf="!library['hidden'] && library['inForeground']"
[(clrExpanded)]="library['expanded']"
[clrLoading]="library['loadingTables'] && !library.tables"
[class.clr-expanded]="library['expanded']"
>
<p
(click)="lib = library.LIBRARYID; libraryOnClick(lib || '', library)"
class="m-0 cursor-pointer"
>
<clr-icon shape="rack-server"></clr-icon>
{{ library.LIBRARYNAME }}
</p>
<clr-tree-node *ngIf="library['tables']" class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchTreeInput
placeholder="Tables"
name="input"
[(ngModel)]="library['searchString']"
(keyup)="treeOnFilter(library, 'tables.TABLENAME')"
autocomplete="off"
/>
<clr-icon
(click)="librariesSearch = ''; libraryOnFilter()"
*ngIf="searchTreeInput.value.length < 1"
shape="search"
></clr-icon>
<clr-icon
*ngIf="searchTreeInput.value.length > 0"
(click)="
searchTreeInput.value = '';
library['searchString'] = '';
treeOnFilter(library, 'tables.TABLENAME')
"
shape="times"
></clr-icon>
}
</div>
</clr-tree-node>
}
</div>
</clr-tree-node>
@for (library of libraryList; track library) {
@if (!library['hidden'] && library['inForeground']) {
<clr-tree-node
(click)="treeNodeClicked($event, library, libraryList)"
[(clrExpanded)]="library['expanded']"
[clrLoading]="library['loadingTables'] && !library.tables"
[class.clr-expanded]="library['expanded']"
(click)="treeNodeClicked($event, libTable, library['tables'])"
*ngFor="let libTable of library['tables']"
[(clrExpanded)]="libTable['expanded']"
[clrLoading]="libTable['loadingColumns'] && !libTable.columns"
[class.clr-expanded]="libTable['expanded']"
>
<p
(click)="
lib = library.LIBRARYID; libraryOnClick(lib || '', library)
"
(click)="tableOnClick(libTable.TABLEURI, libTable, library)"
[id]="libTable.TABLEURI"
class="m-0 cursor-pointer"
>
<clr-icon shape="rack-server"></clr-icon>
{{ library.LIBRARYNAME }}
<clr-icon shape="table"></clr-icon>
{{ libTable.TABLENAME }}
</p>
@if (library['tables']) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchTreeInput
placeholder="Tables"
name="input"
[(ngModel)]="library['searchString']"
(keyup)="treeOnFilter(library, 'tables.TABLENAME')"
autocomplete="off"
/>
@if (searchTreeInput.value.length < 1) {
<clr-icon shape="search"></clr-icon>
}
@if (searchTreeInput.value.length > 0) {
<clr-icon
(click)="
searchTreeInput.value = '';
library['searchString'] = '';
treeOnFilter(library, 'tables.TABLENAME')
"
shape="times"
></clr-icon>
}
</div>
</clr-tree-node>
}
@for (libTable of library['tables']; track libTable) {
<clr-tree-node
(click)="treeNodeClicked($event, libTable, library['tables'])"
[(clrExpanded)]="libTable['expanded']"
[clrLoading]="libTable['loadingColumns'] && !libTable.columns"
[class.clr-expanded]="libTable['expanded']"
<clr-tree-node *ngIf="libTable['columns']" class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchTreeInput
placeholder="Columns"
name="input"
[(ngModel)]="libTable['searchString']"
(keyup)="treeOnFilter(libTable, 'columns.COLNAME')"
autocomplete="off"
/>
<clr-icon
*ngIf="searchTreeInput.value.length < 1"
shape="search"
></clr-icon>
<clr-icon
*ngIf="searchTreeInput.value.length > 0"
(click)="
searchTreeInput.value = '';
libTable['searchString'] = '';
treeOnFilter(libTable, 'columns.COLNAME')
"
shape="times"
></clr-icon>
</div>
</clr-tree-node>
<clr-tree-node *ngFor="let libColumn of libTable['columns']">
<button
(click)="columnOnClick(libColumn, library, libTable)"
class="clr-treenode-link"
[class.column-active]="libColumnActive(libColumn.COLURI)"
>
<p
(click)="tableOnClick(libTable.TABLEURI, libTable, library)"
[id]="libTable.TABLEURI"
class="m-0 cursor-pointer"
>
<clr-icon shape="table"></clr-icon>
{{ libTable.TABLENAME }}
</p>
@if (libTable['columns']) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchTreeInput
placeholder="Columns"
name="input"
[(ngModel)]="libTable['searchString']"
(keyup)="treeOnFilter(libTable, 'columns.COLNAME')"
autocomplete="off"
/>
@if (searchTreeInput.value.length < 1) {
<clr-icon shape="search"></clr-icon>
}
@if (searchTreeInput.value.length > 0) {
<clr-icon
(click)="
searchTreeInput.value = '';
libTable['searchString'] = '';
treeOnFilter(libTable, 'columns.COLNAME')
"
shape="times"
></clr-icon>
}
</div>
</clr-tree-node>
}
@for (libColumn of libTable['columns']; track libColumn) {
<clr-tree-node>
<button
(click)="columnOnClick(libColumn, library, libTable)"
class="clr-treenode-link"
[class.column-active]="libColumnActive(libColumn.COLURI)"
>
<clr-icon shape="objects"></clr-icon>
{{ libColumn.COLNAME }}
</button>
</clr-tree-node>
}
</clr-tree-node>
}
<clr-icon shape="objects"></clr-icon>
{{ libColumn.COLNAME }}
</button>
</clr-tree-node>
</clr-tree-node>
}
}
</clr-tree-node>
</ng-container>
</clr-tree>
@if (librariesPaging) {
<div class="w-100 text-center">
<span class="spinner spinner-sm"> Loading... </span>
</div>
}
<div *ngIf="librariesPaging" class="w-100 text-center">
<span class="spinner spinner-sm"> Loading... </span>
</div>
</app-sidebar>
<div class="content-area">
<div class="card">
@if (!column && !table) {
<div class="no-table-selected">
<clr-icon
shape="warning-standard"
size="60"
class="is-info icon-dc-fill"
></clr-icon>
<p class="text-center color-gray mt-10" cds-text="section">
Please select a column or table
</p>
</div>
}
<div *ngIf="!column && !table" class="no-table-selected">
<clr-icon
shape="warning-standard"
size="60"
class="is-info icon-dc-fill"
></clr-icon>
<p class="text-center color-gray mt-10" cds-text="section">
Please select a column or table
</p>
</div>
<ng-container *ngIf="column || table">
<div
*ngIf="!graphContainer"
class="card-header d-flex flex-column justify-content-center"
>
<h3
*ngIf="!currentLineagePathColumn && !currentLineagePathLibTable"
class="text-center pb-10"
>
{{
currentLineagePathColumn
? currentLineagePathLibTable + '.' + currentLineagePathColumn
: currentLineagePathLibTable
}}
</h3>
</div>
@if (column || table) {
@if (!graphContainer) {
<div class="card-header d-flex flex-column justify-content-center">
@if (!currentLineagePathColumn && !currentLineagePathLibTable) {
<h3 class="text-center pb-10">
{{
currentLineagePathColumn
? currentLineagePathLibTable + '.' + currentLineagePathColumn
: currentLineagePathLibTable
}}
</h3>
}
</div>
}
<div class="card-block">
<section
class="form-block sw position-relative d-flex align-items-center"
@@ -178,115 +170,114 @@
<div
class="linage-title-wrapper d-flex align-items-center font-weight-bold position-absolute"
>
@if (lineageTableName.length > 0) {
<span>
{{ lineageTableName.split('.')[0] }}.<a
[routerLink]="'/view/data/' + lineageTableName"
>{{ lineageTableName.split('.')[1] }}</a
>{{
lineageColumnName.length > 0 ? '.' + lineageColumnName : ''
}}
</span>
}
<span *ngIf="lineageTableName.length > 0">
{{ lineageTableName.split('.')[0] }}.<a
[routerLink]="'/view/data/' + lineageTableName"
>{{ lineageTableName.split('.')[1] }}</a
>{{ lineageColumnName.length > 0 ? '.' + lineageColumnName : '' }}
</span>
</div>
@if (graphContainer) {
<div class="clr-col-md-12 text-center d-flex justify-content-end">
<button
(click)="limitDotDepth = true"
type="button"
class="btn btn-outline mr-5"
<div
*ngIf="graphContainer"
class="clr-col-md-12 text-center d-flex justify-content-end"
>
<button
(click)="limitDotDepth = true"
type="button"
class="btn btn-outline mr-5"
>
Limit depth
</button>
<!-- <button class="btn btn-outline" (click)='showSvg()'> Open in New Tab </button> -->
<div class="btn-group direction d-block">
<div
class="radio btn"
(click)="
forwardLineage = false;
router.url.includes('column')
? onGenerateClick()
: onGenerateGraphTableClick()
"
>
Limit depth
</button>
<!-- <button class="btn btn-outline" (click)='showSvg()'> Open in New Tab </button> -->
<div class="btn-group direction d-block">
<div
class="radio btn"
(click)="
forwardLineage = false;
router.url.includes('column')
? onGenerateClick()
: onGenerateGraphTableClick()
"
>
<input
type="radio"
name="btn-group-demo-radios"
[checked]="!forwardLineage"
/>
<label>Backward</label>
</div>
<div
class="radio btn"
(click)="
forwardLineage = true;
router.url.includes('column')
? onGenerateClick()
: onGenerateGraphTableClick()
"
>
<input
type="radio"
name="btn-group-demo-radios"
[checked]="forwardLineage"
/>
<label>Forward</label>
</div>
<input
type="radio"
name="btn-group-demo-radios"
[checked]="!forwardLineage"
/>
<label>Backward</label>
</div>
<div
class="radio btn"
(click)="
forwardLineage = true;
router.url.includes('column')
? onGenerateClick()
: onGenerateGraphTableClick()
"
>
<input
type="radio"
name="btn-group-demo-radios"
[checked]="forwardLineage"
/>
<label>Forward</label>
</div>
<clr-dropdown class="mr-10">
<button
class="btn btn-info-outline"
clrDropdownTrigger
[disabled]="!column && !table"
>
Download
<clr-icon shape="caret down"></clr-icon>
</button>
<clr-dropdown-menu clrPosition="bottom-left" *clrIfOpen>
<div (click)="downloadSVG()" clrDropdownItem>SVG</div>
<div (click)="downloadPNG()" clrDropdownItem>PNG</div>
<div (click)="downloadDot()" clrDropdownItem>Dot</div>
@if (flatdata) {
<div (click)="downloadCSV()" clrDropdownItem>CSV</div>
}
</clr-dropdown-menu>
</clr-dropdown>
@if (column) {
<clr-checkbox-wrapper class="d-flex align-items-center">
<input
type="checkbox"
(change)="generateGraph()"
clrCheckbox
name="refreshCache"
[(ngModel)]="refreshCache"
/>
<label>Refresh Cache</label>
</clr-checkbox-wrapper>
}
</div>
}
</section>
@if (graphContainer) {
<div [class.mt-2]="tableFlag">
<div class="text-center">
@if (graphLoading) {
<span class="spinner"> Loading... </span>
}
@if (!graphLoading) {
<div class="position-relative">
<div class="graph-render-spinner">
@if (graphRendering) {
<span class="spinner spinner-sm"></span>
}
</div>
<div id="graph"></div>
<clr-dropdown class="mr-10">
<button
class="btn btn-info-outline"
clrDropdownTrigger
[disabled]="!column && !table"
>
Download
<clr-icon shape="caret down"></clr-icon>
</button>
<clr-dropdown-menu clrPosition="bottom-left" *clrIfOpen>
<div (click)="downloadSVG()" clrDropdownItem>SVG</div>
<div (click)="downloadPNG()" clrDropdownItem>PNG</div>
<div (click)="downloadDot()" clrDropdownItem>Dot</div>
<div *ngIf="flatdata" (click)="downloadCSV()" clrDropdownItem>
CSV
</div>
}
</clr-dropdown-menu>
</clr-dropdown>
<clr-checkbox-wrapper
*ngIf="column"
class="d-flex align-items-center"
>
<input
type="checkbox"
(change)="generateGraph()"
clrCheckbox
name="refreshCache"
[(ngModel)]="refreshCache"
/>
<label>Refresh Cache</label>
</clr-checkbox-wrapper>
</div>
</section>
<div *ngIf="graphContainer" [class.mt-2]="tableFlag">
<div class="text-center">
<span *ngIf="graphLoading" class="spinner"> Loading... </span>
<div *ngIf="!graphLoading" class="position-relative">
<div class="graph-render-spinner">
<span *ngIf="graphRendering" class="spinner spinner-sm"></span>
</div>
<div id="graph"></div>
</div>
</div>
}
</div>
</div>
}
</ng-container>
</div>
</div>
+187 -217
View File
@@ -8,60 +8,58 @@
[(ngModel)]="repository"
(change)="updateSelectedRepository()"
>
@for (repository of repositories; track repository) {
<option value="{{ repository }}">
{{ repository }}
</option>
}
<option
*ngFor="let repository of repositories"
value="{{ repository }}"
>
{{ repository }}
</option>
</select>
</clr-select-container>
</div>
<clr-tree>
@if (metaDataList) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchLibTreeInput
placeholder="search SAS Types"
name="input"
[(ngModel)]="metaDataSearch"
(keyup)="metaListOnFilter()"
autocomplete="off"
/>
@if (searchLibTreeInput.value.length < 1) {
<clr-icon shape="search"></clr-icon>
}
@if (searchLibTreeInput.value.length > 0) {
<clr-icon
(click)="metaDataSearch = ''; metaListOnFilter()"
shape="times"
></clr-icon>
}
</div>
</clr-tree-node>
}
@for (metaData of metaDataList; track metaData) {
@if (!metaData['hidden']) {
<clr-tree-node
(click)="treeNodeClicked($event, metaData, metaDataList)"
[(clrExpanded)]="metaData['expanded']"
[clrLoading]="metaData['loadingTables'] && !metaData.tables"
<clr-tree-node *ngIf="metaDataList" class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchLibTreeInput
placeholder="search SAS Types"
name="input"
[(ngModel)]="metaDataSearch"
(keyup)="metaListOnFilter()"
autocomplete="off"
/>
<clr-icon
*ngIf="searchLibTreeInput.value.length < 1"
shape="search"
></clr-icon>
<clr-icon
*ngIf="searchLibTreeInput.value.length > 0"
(click)="metaDataSearch = ''; metaListOnFilter()"
shape="times"
></clr-icon>
</div>
</clr-tree-node>
<ng-container *ngFor="let metaData of metaDataList">
<clr-tree-node
(click)="treeNodeClicked($event, metaData, metaDataList)"
*ngIf="!metaData['hidden']"
[(clrExpanded)]="metaData['expanded']"
[clrLoading]="metaData['loadingTables'] && !metaData.tables"
>
<p
(click)="
metaDataId = metaData.ID; metaDataOnClick(metaDataId, metaData)
"
class="m-0 cursor-pointer"
>
<p
(click)="
metaDataId = metaData.ID; metaDataOnClick(metaDataId, metaData)
"
class="m-0 cursor-pointer"
>
<clr-icon shape="block"></clr-icon>
{{ metaData.ID }}
</p>
</clr-tree-node>
}
}
<clr-icon shape="block"></clr-icon>
{{ metaData.ID }}
</p>
</clr-tree-node>
</ng-container>
</clr-tree>
</app-sidebar>
@@ -69,121 +67,54 @@
<div class="card background-transparent-i">
<h3 class="color-gray">{{ assoTypeSelected }}</h3>
@if (!loading && !metaObjectList) {
<div class="no-table-selected">
<clr-icon
shape="warning-standard"
size="60"
class="is-info icon-dc-fill"
></clr-icon>
<p class="text-center color-gray mt-10" cds-text="section">
Please select a type
</p>
</div>
}
<div *ngIf="!loading && !metaObjectList" class="no-table-selected">
<clr-icon
shape="warning-standard"
size="60"
class="is-info icon-dc-fill"
></clr-icon>
<p class="text-center color-gray mt-10" cds-text="section">
Please select a type
</p>
</div>
@if (loading) {
<div class="loadingSpinner">
<span class="spinner"> Loading... </span>
@if (loading) {
<div>
@if (metatypesLoading) {
<h4>Loading metadata types</h4>
}
@if (!metatypesLoading) {
<h4>Loading metadata objects</h4>
}
</div>
}
<div class="loadingSpinner" *ngIf="loading">
<span class="spinner"> Loading... </span>
<div *ngIf="loading">
<h4 *ngIf="metatypesLoading">Loading metadata types</h4>
<h4 *ngIf="!metatypesLoading">Loading metadata objects</h4>
</div>
}
@if (showData) {
<div class="clr-row clr-flex-grow-1">
<div class="clr-col-6">
@if (metaObjectList && !objectRoute) {
<div class="search-input">
<input
clrInput
#searchObjTreeInput
placeholder="search"
name="input"
[(ngModel)]="metaObjectSearch"
(keyup)="metaObjectOnFilter()"
autocomplete="off"
/>
</div>
}
<br />
@if (!objectView) {
<div class="objects-col">
<clr-accordion>
@for (metaObject of metaObjectShowList; track metaObject) {
@if (!metaObject['hidden']) {
<clr-accordion-panel
(clrAccordionPanelOpenChange)="
$event ? panelChange($event, metaObject) : ''
"
>
<clr-accordion-title
><clr-icon shape="rack-server"></clr-icon>
{{ metaObject.NAME }}
<p class="float-right">{{ metaObject.ID }}</p>
</clr-accordion-title>
<clr-accordion-content *clrIfExpanded>
@if (showAcc) {
<clr-tree [clrLazy]="true">
<clr-tree-node
*clrRecursiveFor="
let entry of root$ | async;
getChildren: getChildren
"
[clrExpandable]="true"
>
<div
[class.object-header]="!entry.count"
class="full-width"
>
<div>
@if (!entry.count) {
<clr-icon shape="rack-server"></clr-icon>
}
@if (entry.count) {
<clr-icon shape="block"></clr-icon>
}
{{ entry.display }}
</div>
@if (!entry.count) {
<p class="float-right object-uri">
{{ entry.URI }}
</p>
}
</div>
</clr-tree-node>
</clr-tree>
}
</clr-accordion-content>
</clr-accordion-panel>
}
}
</clr-accordion>
</div>
}
@if (objectView) {
<div class="objects-col">
@for (metaObject of metaObjectShowList; track metaObject) {
<div class="cols-head">
<clr-icon shape="rack-server"></clr-icon>
<div class="object-text">
<p class="m-0 word-break mr-20">
{{ metaObject.NAME }}
</p>
<p class="float-right ml-3">
{{ metaObject.ID }}
</p>
</div>
</div>
@if (showAcc) {
<clr-tree [clrLazy]="true">
</div>
<div *ngIf="showData" class="clr-row clr-flex-grow-1">
<div class="clr-col-6">
<div *ngIf="metaObjectList && !objectRoute" class="search-input">
<input
clrInput
#searchObjTreeInput
placeholder="search"
name="input"
[(ngModel)]="metaObjectSearch"
(keyup)="metaObjectOnFilter()"
autocomplete="off"
/>
</div>
<br />
<div *ngIf="!objectView" class="objects-col">
<clr-accordion>
<ng-container *ngFor="let metaObject of metaObjectShowList">
<clr-accordion-panel
(clrAccordionPanelOpenChange)="
$event ? panelChange($event, metaObject) : ''
"
*ngIf="!metaObject['hidden']"
>
<clr-accordion-title
><clr-icon shape="rack-server"></clr-icon>
{{ metaObject.NAME }}
<p class="float-right">{{ metaObject.ID }}</p>
</clr-accordion-title>
<clr-accordion-content *clrIfExpanded>
<clr-tree *ngIf="showAcc" [clrLazy]="true">
<clr-tree-node
*clrRecursiveFor="
let entry of root$ | async;
@@ -196,70 +127,109 @@
class="full-width"
>
<div>
@if (!entry.count) {
<clr-icon shape="rack-server"></clr-icon>
}
@if (entry.count) {
<clr-icon shape="block"></clr-icon>
}
<clr-icon
*ngIf="!entry.count"
shape="rack-server"
></clr-icon>
<clr-icon
*ngIf="entry.count"
shape="block"
></clr-icon>
{{ entry.display }}
</div>
@if (!entry.count) {
<p class="float-right object-uri">
{{ entry.URI }}
</p>
}
<p class="float-right object-uri" *ngIf="!entry.count">
{{ entry.URI }}
</p>
</div>
</clr-tree-node>
</clr-tree>
}
}
</div>
}
</clr-accordion-content>
</clr-accordion-panel>
</ng-container>
</clr-accordion>
</div>
<div class="clr-col-6 text-center">
<h3>{{ assoObjectSelected }}</h3>
@if (showTable) {
<clr-datagrid class="datagrid-custom-footer">
<clr-dg-column>
TYPE
<clr-dg-string-filter
[clrDgStringFilter]="typeFilter"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column>
NAME
<clr-dg-string-filter
[clrDgStringFilter]="nameFilter"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column>
VALUE
<clr-dg-string-filter
[clrDgStringFilter]="valueFilter"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-row
*clrDgItems="let metaObjectAttribute of metaObjectAttributes"
<div *ngIf="objectView" class="objects-col">
<ng-container *ngFor="let metaObject of metaObjectShowList">
<div class="cols-head">
<clr-icon shape="rack-server"></clr-icon>
<div class="object-text">
<p class="m-0 word-break mr-20">
{{ metaObject.NAME }}
</p>
<p class="float-right ml-3">
{{ metaObject.ID }}
</p>
</div>
</div>
<clr-tree *ngIf="showAcc" [clrLazy]="true">
<clr-tree-node
*clrRecursiveFor="
let entry of root$ | async;
getChildren: getChildren
"
[clrExpandable]="true"
>
<clr-dg-cell>{{ metaObjectAttribute.TYPE }}</clr-dg-cell>
<clr-dg-cell>{{ metaObjectAttribute.NAME }}</clr-dg-cell>
<clr-dg-cell>{{ metaObjectAttribute.VALUE }}</clr-dg-cell>
</clr-dg-row>
<clr-dg-footer>
<clr-dg-pagination #pagination [clrDgPageSize]="10">
<clr-dg-page-size [clrPageSizeOptions]="[10, 20, 50, 100]"
>Attributes per page</clr-dg-page-size
>
{{ pagination.firstItem + 1 }} -
{{ pagination.lastItem + 1 }} of
{{ pagination.totalItems }} Attributes
</clr-dg-pagination>
</clr-dg-footer>
</clr-datagrid>
}
<div [class.object-header]="!entry.count" class="full-width">
<div>
<clr-icon
*ngIf="!entry.count"
shape="rack-server"
></clr-icon>
<clr-icon *ngIf="entry.count" shape="block"></clr-icon>
{{ entry.display }}
</div>
<p class="float-right object-uri" *ngIf="!entry.count">
{{ entry.URI }}
</p>
</div>
</clr-tree-node>
</clr-tree>
</ng-container>
</div>
</div>
}
<div class="clr-col-6 text-center">
<h3>{{ assoObjectSelected }}</h3>
<clr-datagrid class="datagrid-custom-footer" *ngIf="showTable">
<clr-dg-column>
TYPE
<clr-dg-string-filter
[clrDgStringFilter]="typeFilter"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column>
NAME
<clr-dg-string-filter
[clrDgStringFilter]="nameFilter"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column>
VALUE
<clr-dg-string-filter
[clrDgStringFilter]="valueFilter"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-row
*clrDgItems="let metaObjectAttribute of metaObjectAttributes"
>
<clr-dg-cell>{{ metaObjectAttribute.TYPE }}</clr-dg-cell>
<clr-dg-cell>{{ metaObjectAttribute.NAME }}</clr-dg-cell>
<clr-dg-cell>{{ metaObjectAttribute.VALUE }}</clr-dg-cell>
</clr-dg-row>
<clr-dg-footer>
<clr-dg-pagination #pagination [clrDgPageSize]="10">
<clr-dg-page-size [clrPageSizeOptions]="[10, 20, 50, 100]"
>Attributes per page</clr-dg-page-size
>
{{ pagination.firstItem + 1 }} - {{ pagination.lastItem + 1 }} of
{{ pagination.totalItems }} Attributes
</clr-dg-pagination>
</clr-dg-footer>
</clr-datagrid>
</div>
</div>
</div>
</div>
@@ -1,14 +1,9 @@
import {
DynamicCellValidation,
DynamicExtendedCellValidation
} from '../editor/models/dynamicExtendedCellValidation'
export interface CellValidationSource {
col: number
row: number
strict: boolean
values: DynamicCellValidation[]
extended_values?: DynamicExtendedCellValidation[]
values: any[]
extended_values?: string[]
hash: string
count: number
pending?: Promise<void>
+1
View File
@@ -37,6 +37,7 @@ export interface MaxVarLength {
export interface SASParam {
COLHEADERS: string
COLTYPE: string
DTTMVARS: string
DTVARS: string
CLS_FLAG: number
@@ -1,118 +1,117 @@
<app-sidebar>
@if (datasetsLoading) {
<div class="my-10-mx-auto text-center">
<clr-spinner clrMedium></clr-spinner>
</div>
}
<div *ngIf="datasetsLoading" class="my-10-mx-auto text-center">
<clr-spinner clrMedium></clr-spinner>
</div>
@if (!parsedDatasets.length) {
<div class="text-center mb-10">
<button
(click)="fileUploadInput.click()"
id="browse-file"
class="btn btn-primary btn-sm"
[disabled]="selectedFile !== null || submittingCsv"
>
Browse file
<div *ngIf="!parsedDatasets.length" class="text-center mb-10">
<button
(click)="fileUploadInput.click()"
id="browse-file"
class="btn btn-primary btn-sm"
[disabled]="selectedFile !== null || submittingCsv"
>
Browse file
</button>
<input
hidden
#fileUploadInput
id="file-upload"
type="file"
(change)="onFileChange($event)"
multiple
/>
</div>
<ng-container *ngIf="parsedDatasets.length && !submittedCsvDatasets.length">
<div *ngIf="!excelsSubmitted" class="text-center mb-10">
<button (click)="onDiscard()" class="btn btn-danger btn-sm mr-10">
Discard
</button>
<button
(click)="onSubmitAll()"
id="submit-all"
class="btn btn-primary btn-sm"
>
Submit All
</button>
<input
hidden
#fileUploadInput
id="file-upload"
type="file"
(change)="onFileChange($event)"
multiple
/>
</div>
}
@if (parsedDatasets.length && !submittedCsvDatasets.length) {
@if (!excelsSubmitted) {
<div class="text-center mb-10">
<button (click)="onDiscard()" class="btn btn-danger btn-sm mr-10">
Discard
</button>
<button
(click)="onSubmitAll()"
id="submit-all"
class="btn btn-primary btn-sm"
>
Submit All
</button>
</div>
}
<p cds-text="caption" class="ml-10 mb-10">Found tables:</p>
<clr-tree>
@for (dataset of parsedDatasets; track dataset) {
<clr-tree-node>
<button
(click)="onParsedDatasetClick(dataset)"
class="clr-treenode-link whitespace-nowrap d-flex clr-align-items-center"
[class.active]="dataset.active"
>
@if (dataset.submitResult) {
@if (dataset.submitResult.error) {
<cds-icon status="danger" shape="exclamation-circle"></cds-icon>
}
@if (dataset.submitResult.success) {
<cds-icon status="success" shape="check-circle"></cds-icon>
}
}
@if (!dataset.submitResult) {
@if (dataset.datasource) {
@if (!(dataset.datasource.length && dataset.parseResult)) {
<cds-icon
status="danger"
shape="exclamation-circle"
></cds-icon>
}
@if (dataset.datasource.length && dataset.parseResult) {
<cds-icon shape="table"></cds-icon>
}
}
@if (!dataset.datasource) {
@if (!dataset.parsingTable) {
<cds-icon shape="table"></cds-icon>
}
@if (dataset.parsingTable) {
<clr-spinner clrSmall></clr-spinner>
}
}
}
<span class="ml-5"> {{ dataset.libds }} </span>
</button>
</clr-tree-node>
}
</clr-tree>
}
<clr-tree-node *ngFor="let dataset of parsedDatasets">
<button
(click)="onParsedDatasetClick(dataset)"
class="clr-treenode-link whitespace-nowrap d-flex clr-align-items-center"
[class.active]="dataset.active"
>
<ng-container *ngIf="dataset.submitResult">
<cds-icon
*ngIf="dataset.submitResult.error"
status="danger"
shape="exclamation-circle"
></cds-icon>
<cds-icon
*ngIf="dataset.submitResult.success"
status="success"
shape="check-circle"
></cds-icon>
</ng-container>
@if (submittedCsvDatasets.length) {
<ng-container *ngIf="!dataset.submitResult">
<ng-container *ngIf="dataset.datasource">
<cds-icon
*ngIf="!(dataset.datasource.length && dataset.parseResult)"
status="danger"
shape="exclamation-circle"
></cds-icon>
<cds-icon
*ngIf="dataset.datasource.length && dataset.parseResult"
shape="table"
></cds-icon>
</ng-container>
<ng-container *ngIf="!dataset.datasource">
<cds-icon *ngIf="!dataset.parsingTable" shape="table"></cds-icon>
<clr-spinner *ngIf="dataset.parsingTable" clrSmall></clr-spinner>
</ng-container>
</ng-container>
<span class="ml-5"> {{ dataset.libds }} </span>
</button>
</clr-tree-node>
</clr-tree>
</ng-container>
<ng-container *ngIf="submittedCsvDatasets.length">
<p cds-text="caption" class="ml-10 mb-10 mt-10">Submitted tables:</p>
<clr-tree>
@for (dataset of submittedCsvDatasets; track dataset) {
<clr-tree-node>
<button
(click)="onSubmittedCsvDatasetClick(dataset)"
class="clr-treenode-link whitespace-nowrap"
[class.active]="dataset.active"
>
@if (dataset.error) {
<cds-icon status="danger" shape="exclamation-circle"></cds-icon>
}
@if (dataset.success) {
<cds-icon status="success" shape="check-circle"></cds-icon>
}
<cds-icon shape="table"></cds-icon>
{{ dataset.libds }}
</button>
</clr-tree-node>
}
<clr-tree-node *ngFor="let dataset of submittedCsvDatasets">
<button
(click)="onSubmittedCsvDatasetClick(dataset)"
class="clr-treenode-link whitespace-nowrap"
[class.active]="dataset.active"
>
<cds-icon
*ngIf="dataset.error"
status="danger"
shape="exclamation-circle"
></cds-icon>
<cds-icon
*ngIf="dataset.success"
status="success"
shape="check-circle"
></cds-icon>
<cds-icon shape="table"></cds-icon>
{{ dataset.libds }}
</button>
</clr-tree-node>
</clr-tree>
}
</ng-container>
<!-- <div *ngIf="librariesPaging" class="w-100 text-center">
<span class="spinner spinner-sm"> Loading... </span>
</div> -->
<span class="spinner spinner-sm"> Loading... </span>
</div> -->
</app-sidebar>
<div #contentArea class="content-area">
@@ -123,22 +122,23 @@
<p cds-text="section">Multi Dataset Load</p>
</div>
@if (selectedFile === null && !submittingCsv) {
<div class="no-table-selected pointer-events-none">
<clr-icon
aria-hidden="true"
shape="upload-cloud"
size="40"
class="is-info icon-dc-fill"
></clr-icon>
<p class="text-center color-gray mt-10" cds-text="section">
Please upload a file
</p>
</div>
}
<div
*ngIf="selectedFile === null && !submittingCsv"
class="no-table-selected pointer-events-none"
>
<clr-icon
aria-hidden="true"
shape="upload-cloud"
size="40"
class="is-info icon-dc-fill"
></clr-icon>
<p class="text-center color-gray mt-10" cds-text="section">
Please upload a file
</p>
</div>
@if (selectedFile !== null || submittingCsv) {
@if (!parsedDatasets.length && selectedFile !== null) {
<ng-container *ngIf="selectedFile !== null || submittingCsv">
<ng-container *ngIf="!parsedDatasets.length && selectedFile !== null">
<div class="d-flex clr-justify-content-center mt-15">
<div class="dataset-input-wrapper">
<p cds-text="secondary regular" class="mb-5">
@@ -160,10 +160,12 @@
<p cds-text="secondary regular" class="mb-15">
Paste or type the list of datasets to upload:
</p>
<clr-control-helper class="mb-5"
>Each row is one dataset. We will automatically detect tables by
the sheetname and populate if any.</clr-control-helper
>
<hot-table
#hotInstanceUserDataset
id="hotTableUserDataset"
@@ -171,6 +173,7 @@
[settings]="hotUserDatasetsSettings"
>
</hot-table>
<div class="dataset-selection-actions text-right mt-10">
<button
(click)="onStartParsingFile()"
@@ -184,90 +187,109 @@
</div>
</div>
</div>
}
@if (parsedDatasets.length && !submittedCsvDatasets.length) {
@if (!activeParsedDataset) {
<div class="no-table-selected pointer-events-none">
@if (fileLoadingState !== FileLoadingState.parsed) {
<clr-icon
aria-hidden="true"
shape="process-on-vm"
size="40"
class="is-info icon-dc-fill"
></clr-icon>
<p class="text-center color-gray mt-10" cds-text="section">
{{ fileLoadingState }}...
</p>
}
@if (fileLoadingState === FileLoadingState.parsed) {
<clr-icon
aria-hidden="true"
shape="warning-standard"
size="40"
class="is-info icon-dc-fill"
></clr-icon>
<p class="text-center color-gray mt-10" cds-text="section">
Please select a dataset on the left to review the data
</p>
}
</div>
}
@if (activeParsedDataset) {
@if (activeParsedDataset.submitResult) {
<div
class="d-flex clr-justify-content-between p-10 mt-15 submission-results"
>
<div>
<p cds-text="secondary regular" class="mb-10">
Submit Status:
@if (activeParsedDataset.submitResult.success) {
<span class="color-green"><strong>SUCCESS</strong></span>
}
@if (activeParsedDataset.submitResult.error) {
<span class="color-red"><strong>ERROR</strong></span>
}
</p>
@if (activeParsedDataset.submitResult.error) {
<p cds-text="secondary regular">Error details:</p>
}
</div>
<div>
@if (!submittingCsv && activeParsedDataset.submitResult.error) {
<button
(click)="reSubmitTable(activeParsedDataset)"
class="btn btn-primary mt-10"
[clrLoading]="submitLoading"
>
Resubmit
</button>
}
<button
(click)="
downloadFile(
activeParsedDataset.submitResult.log ||
activeParsedDataset.submitResult.success ||
activeParsedDataset.submitResult.error
)
"
class="btn btn-primary-outline mt-10"
</ng-container>
<ng-container
*ngIf="parsedDatasets.length && !submittedCsvDatasets.length"
>
<div
*ngIf="!activeParsedDataset"
class="no-table-selected pointer-events-none"
>
<ng-container *ngIf="fileLoadingState !== FileLoadingState.parsed">
<clr-icon
aria-hidden="true"
shape="process-on-vm"
size="40"
class="is-info icon-dc-fill"
></clr-icon>
<p class="text-center color-gray mt-10" cds-text="section">
{{ fileLoadingState }}...
</p>
</ng-container>
<ng-container *ngIf="fileLoadingState === FileLoadingState.parsed">
<clr-icon
aria-hidden="true"
shape="warning-standard"
size="40"
class="is-info icon-dc-fill"
></clr-icon>
<p class="text-center color-gray mt-10" cds-text="section">
Please select a dataset on the left to review the data
</p>
</ng-container>
</div>
<ng-container *ngIf="activeParsedDataset">
<div
*ngIf="activeParsedDataset.submitResult"
class="d-flex clr-justify-content-between p-10 mt-15 submission-results"
>
<div>
<p cds-text="secondary regular" class="mb-10">
Submit Status:
<span
*ngIf="activeParsedDataset.submitResult?.success"
class="color-green"
><strong>SUCCESS</strong></span
>
Download log
</button>
</div>
<span
*ngIf="activeParsedDataset.submitResult?.error"
class="color-red"
><strong>ERROR</strong></span
>
</p>
<p
*ngIf="activeParsedDataset.submitResult?.error"
cds-text="secondary regular"
>
Error details:
</p>
</div>
}
@if (activeParsedDataset.submitResult?.error) {
<div class="error-field mt-15">
<div class="log-wrapper">
{{ activeParsedDataset.submitResult?.error | json }}
</div>
<div>
<button
*ngIf="
!submittingCsv && activeParsedDataset.submitResult?.error
"
(click)="reSubmitTable(activeParsedDataset)"
class="btn btn-primary mt-10"
[clrLoading]="submitLoading"
>
Resubmit
</button>
<button
(click)="
downloadFile(
activeParsedDataset.submitResult.log ||
activeParsedDataset.submitResult.success ||
activeParsedDataset.submitResult.error
)
"
class="btn btn-primary-outline mt-10"
>
Download log
</button>
</div>
}
</div>
<div
*ngIf="activeParsedDataset.submitResult?.error"
class="error-field mt-15"
>
<div class="log-wrapper">
{{ activeParsedDataset.submitResult?.error | json }}
</div>
</div>
<div class="d-flex clr-justify-content-between p-10 mt-15">
<div>
<p cds-text="secondary regular" class="mb-10">
Found in range:
@if (activeParsedDataset.parseResult) {
<ng-container *ngIf="activeParsedDataset.parseResult">
<strong
>"{{
activeParsedDataset.parseResult.rangeSheetRes?.sheetName
@@ -276,18 +298,21 @@
?.rangeAddress
}}</strong
>
}
@if (!activeParsedDataset.parseResult) {
@if (!activeParsedDataset.parsingTable) {
<strong>No data found</strong>
}
@if (activeParsedDataset.parsingTable) {
<span class="d-flex clr-align-items-center">
<strong>Searching for the data...</strong>
<clr-spinner class="ml-5" clrSmall></clr-spinner>
</span>
}
}
</ng-container>
<ng-container *ngIf="!activeParsedDataset.parseResult">
<strong *ngIf="!activeParsedDataset.parsingTable"
>No data found</strong
>
<span
*ngIf="activeParsedDataset.parsingTable"
class="d-flex clr-align-items-center"
>
<strong>Searching for the data...</strong>
<clr-spinner class="ml-5" clrSmall></clr-spinner>
</span>
</ng-container>
</p>
<p cds-text="secondary regular">
Dataset:
@@ -308,6 +333,7 @@
</strong>
</p>
</div>
<div>
<clr-toggle-wrapper>
<input
@@ -328,11 +354,11 @@
</clr-toggle-wrapper>
</div>
</div>
@if (isHotHidden) {
<div class="text-center w-100">
<clr-spinner class="spinner-md"></clr-spinner>
</div>
}
<div *ngIf="isHotHidden" class="text-center w-100">
<clr-spinner class="spinner-md"></clr-spinner>
</div>
<hot-table
#hotInstanceMain
id="hotTable"
@@ -340,24 +366,27 @@
[settings]="hotMainTableSettings"
>
</hot-table>
}
}
@if (submittedCsvDatasets.length) {
@if (!activeSubmittedCsvDataset) {
<div class="no-table-selected pointer-events-none">
<clr-icon
aria-hidden="true"
shape="warning-standard"
size="40"
class="is-info icon-dc-fill"
></clr-icon>
<p class="text-center color-gray mt-10" cds-text="section">
Please select a dataset on the left to review the submit results
</p>
</div>
}
}
@if (activeSubmittedCsvDataset) {
</ng-container>
</ng-container>
<ng-container *ngIf="submittedCsvDatasets.length">
<div
*ngIf="!activeSubmittedCsvDataset"
class="no-table-selected pointer-events-none"
>
<clr-icon
aria-hidden="true"
shape="warning-standard"
size="40"
class="is-info icon-dc-fill"
></clr-icon>
<p class="text-center color-gray mt-10" cds-text="section">
Please select a dataset on the left to review the submit results
</p>
</div>
</ng-container>
<ng-container *ngIf="activeSubmittedCsvDataset">
<div class="d-flex clr-justify-content-between p-10">
<div>
<p cds-text="secondary regular" class="mb-10">
@@ -380,17 +409,23 @@
</p>
<p cds-text="secondary regular" class="mb-10">
Status:
@if (activeSubmittedCsvDataset.success) {
<span class="color-green"><strong>SUCCESS</strong></span>
}
@if (activeSubmittedCsvDataset.error) {
<span class="color-red"><strong>ERROR</strong></span>
}
<span
*ngIf="activeSubmittedCsvDataset.success"
class="color-green"
><strong>SUCCESS</strong></span
>
<span *ngIf="activeSubmittedCsvDataset.error" class="color-red"
><strong>ERROR</strong></span
>
</p>
<p
*ngIf="activeSubmittedCsvDataset.error"
cds-text="secondary regular"
>
Error details:
</p>
@if (activeSubmittedCsvDataset.error) {
<p cds-text="secondary regular">Error details:</p>
}
</div>
<div>
<button
(click)="
@@ -405,29 +440,28 @@
</button>
</div>
</div>
@if (activeSubmittedCsvDataset.error) {
<div class="error-field mt-15">
<div class="log-wrapper">
{{ activeSubmittedCsvDataset.error | json }}
</div>
<div *ngIf="activeSubmittedCsvDataset.error" class="error-field mt-15">
<div class="log-wrapper">
{{ activeSubmittedCsvDataset.error | json }}
</div>
}
}
}
</div>
</ng-container>
</ng-container>
<!-- <div>
<p
<p
*ngIf="
licenceState.value.viewer_rows_allowed !== Infinity &&
hotTable.data &&
hotTable.data.length > licenceState.value.viewer_rows_allowed
"
class="mt-2-i w-100 text-center"
>
To display more than {{ licenceState.value.viewer_rows_allowed }} rows,
contact <contact-link />
</p>
</div> -->
class="mt-2-i w-100 text-center"
>
To display more than {{ licenceState.value.viewer_rows_allowed }} rows,
contact <contact-link />
</p>
</div> -->
</div>
</div>
@@ -437,14 +471,16 @@
{{ tablesToSubmit.length === 1 ? 'table' : 'tables' }} for approval
</h3>
<div class="modal-body">
@if (licenceState.value.submit_rows_limit !== Infinity) {
<p cds-text="body" class="licence-limit-notice mt-0 mb-15">
Due to current licence, only
{{ licenceState.value.submit_rows_limit }} rows in each file will be
submitted. To remove the restriction, contact
support&#64;datacontroller.io.
</p>
}
<p
*ngIf="licenceState.value.submit_rows_limit !== Infinity"
cds-text="body"
class="licence-limit-notice mt-0 mb-15"
>
Due to current licence, only
{{ licenceState.value.submit_rows_limit }} rows in each file will be
submitted. To remove the restriction, contact
support&#64;datacontroller.io.
</p>
<div class="text-area-full-width">
<label for="formFields_8" class="mb-5 d-block">Message</label>
@@ -22,9 +22,10 @@ import { ExcelRule } from '../models/TableData'
import { HotTableInterface } from '../models/HotTable.interface'
import { Col } from '../shared/dc-validator/models/col.model'
import { SpreadsheetService } from '../services/spreadsheet.service'
import Handsontable, { CellChange, ChangeSource } from 'handsontable'
import Handsontable from 'handsontable'
import { HotTableComponent } from '@handsontable/angular-wrapper'
import { EditorsStageDataSASResponse } from '../models/sas/editors-stagedata.model'
import { CellChange, ChangeSource } from 'handsontable/common'
import { baseAfterGetColHeader } from '../shared/utils/hot.utils'
import { ColumnSettings } from 'handsontable/settings'
import { UploadFile } from '@sasjs/adapter'
@@ -501,9 +502,7 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
if (changes) {
for (let change of changes) {
if (change && change[3]) {
// CellValue is `unknown` in HOT 18 (was a concrete union before);
// this column is always a dataset name string.
change[3] = (change[3] as string).toUpperCase()
change[3] = change[3].toUpperCase()
}
}
}
@@ -553,9 +552,7 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
dynamicCellValidations() {
if (!this.hotInstanceUserDataset) return
// getData() returns unknown[][] in HOT 18 (was CellValue[][]); this grid's
// data is always library/table name strings.
const hotData = this.hotInstanceUserDataset.getData() as string[][]
const hotData = this.hotInstanceUserDataset.getData()
hotData.forEach((row, rowIndex) => {
const library = row[0]
@@ -576,12 +573,10 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
if (dataAtRow && dataAtRow[0] && dataAtRow[1]) {
if (!this.matchedDatasets.includes(dataset)) {
// getCellMetaAtRow() returns Record<string, unknown>[] in HOT 18, so
// `.col` needs casting back to number for setCellMeta() (same below).
cellMetaAtRow.forEach((cellMeta) => {
this.hotInstanceUserDataset.setCellMeta(
row,
cellMeta.col as number,
cellMeta.col,
'className',
'not-matched'
)
@@ -590,7 +585,7 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
cellMetaAtRow.forEach((cellMeta) => {
this.hotInstanceUserDataset.setCellMeta(
row,
cellMeta.col as number,
cellMeta.col,
'className',
''
)
@@ -600,7 +595,7 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
cellMetaAtRow.forEach((cellMeta) => {
this.hotInstanceUserDataset.setCellMeta(
row,
cellMeta.col as number,
cellMeta.col,
'className',
''
)
@@ -996,8 +991,7 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
private getDatasetsFromHot(): string[] {
if (!this.hotInstanceUserDataset) return []
// getData() returns unknown[][] in HOT 18; see dynamicCellValidations() above.
const hotData = this.hotInstanceUserDataset.getData() as string[][]
const hotData = this.hotInstanceUserDataset.getData()
return hotData
.filter((row) => row[0]?.length && row[1]?.length)
+294 -304
View File
@@ -30,21 +30,20 @@
(ngModelChange)="setGroupLogic(groupLogic)"
clrSelect
>
@for (logic of logicOperators; track logic) {
<option [selected]="logicOperators[0]">
{{ logic }}
</option>
}
<option
*ngFor="let logic of logicOperators"
[selected]="logicOperators[0]"
>
{{ logic }}
</option>
</select>
</clr-select-container>
</div>
<div class="clr-col-md-10 mb-30">
<pre class="line-numbers language-markup">
@if (whereClauseLoading) {
<div class="progresStatic progress loop">
<progress></progress>
</div>
}
<div *ngIf="whereClauseLoading" class="progresStatic progress loop">
<progress></progress>
</div>
<code class="language-sql">{{whereClause}}</code>
</pre>
@@ -56,246 +55,245 @@
class="clauses-container clr-col-md-12"
[class.clr-col-md-10]="clauses?.queryObj?.length > 1"
>
@if (clauses?.queryObj?.length > 1) {
<div></div>
}
<div *ngIf="clauses?.queryObj?.length > 1"></div>
@for (clause of clauses.queryObj; track clause; let clauseIndex = $index) {
<div class="clause-row">
<div class="clr-row" [class.invalid-clause]="clause.invalidClause">
<div class="clause-logic clr-col-md-2">
<div class="select">
<div
class="clause-row"
*ngFor="let clause of clauses.queryObj; let clauseIndex = index"
>
<div class="clr-row" [class.invalid-clause]="clause.invalidClause">
<div class="clause-logic clr-col-md-2">
<div class="select">
<clr-select-container>
<label>Logic</label>
<select
[(ngModel)]="clause.clauseLogic"
(ngModelChange)="setLogic()"
[disabled]="clause.elements.length < 2"
clrSelect
>
<option
*ngFor="let logic of logicOperators"
[selected]="logicOperators[0]"
>
{{ logic }}
</option>
</select>
</clr-select-container>
</div>
<br />
<button
*ngIf="innerWidth > 768"
class="btn btn-primary btn-block mt-10"
(click)="addGroupClause()"
>
<clr-icon shape="plus"></clr-icon>
<span>Group</span>
</button>
</div>
<div class="clause-query clr-col-md-10">
<clr-icon
*ngIf="clauses.queryObj.length > 1"
(click)="removeGroupClause(clauseIndex)"
shape="times"
size="36"
class="remove-group-clause-button"
></clr-icon>
<div
class="clr-row"
*ngFor="let query of clause.elements; let queryIndex = index"
[class.invalid-clause]="query.invalidClause"
>
<!-- VARIABLE -->
<div class="variable-col form-group clr-col-md-3">
<div class="datalist-wrapper">
<app-soft-select
label="Variable"
[id]="'select_vals_var_id' + queryIndex + '_' + clauseIndex"
[inputId]="'vals_var_id' + queryIndex + '_' + clauseIndex"
[emitOnlySelected]="true"
[(value)]="query.variable"
(onInputEvent)="
variableInputChange(
query.variable,
queryIndex,
clauseIndex,
$event
)
"
>
<option *ngFor="let column of cols">
{{ column.NAME }}
</option>
</app-soft-select>
</div>
</div>
<!-- OPERATOR -->
<div class="operator-col form-group clr-col-md-3">
<clr-select-container>
<label>Logic</label>
<label>Operator</label>
<select
[(ngModel)]="clause.clauseLogic"
(ngModelChange)="setLogic()"
[disabled]="clause.elements.length < 2"
[(ngModel)]="query.operator"
(ngModelChange)="
setVariableOperator(queryIndex, query.operator, clauseIndex)
"
clrSelect
>
@for (logic of logicOperators; track logic) {
<option [selected]="logicOperators[0]">
{{ logic }}
</option>
}
<option *ngFor="let opr of query.operators">{{ opr }}</option>
</select>
</clr-select-container>
</div>
<br />
@if (innerWidth > 768) {
<button
class="btn btn-primary btn-block mt-10"
(click)="addGroupClause()"
<!-- VALUE -->
<div
*ngVar="
query.ddtype === 'DATE' ||
query.ddtype === 'DATETIME' ||
query.ddtype === 'TIME' as isDateTime
"
class="value-col form-group clr-col-md-3"
>
<div
*ngIf="query.operator === 'IN' || query.operator === 'NOT IN'"
class="checkbox-vals"
>
<clr-icon shape="plus"></clr-icon>
<span>Group</span>
</button>
}
</div>
<div class="clause-query clr-col-md-10">
@if (clauses.queryObj.length > 1) {
<clr-icon
(click)="removeGroupClause(clauseIndex)"
shape="times"
size="36"
class="remove-group-clause-button"
></clr-icon>
}
@for (
query of clause.elements;
track query;
let queryIndex = $index
) {
<div class="clr-row" [class.invalid-clause]="query.invalidClause">
<!-- VARIABLE -->
<div class="variable-col form-group clr-col-md-3">
<div class="datalist-wrapper">
<app-soft-select
label="Variable"
[id]="
'select_vals_var_id' + queryIndex + '_' + clauseIndex
"
[inputId]="'vals_var_id' + queryIndex + '_' + clauseIndex"
[emitOnlySelected]="true"
[(value)]="query.variable"
(onInputEvent)="
variableInputChange(
query.variable,
queryIndex,
clauseIndex,
$event
)
"
>
@for (column of cols; track column) {
<option>
{{ column.NAME }}
</option>
}
</app-soft-select>
</div>
</div>
<!-- OPERATOR -->
<div class="operator-col form-group clr-col-md-3">
<clr-select-container>
<label>Operator</label>
<select
[(ngModel)]="query.operator"
(ngModelChange)="
setVariableOperator(
queryIndex,
query.operator,
clauseIndex
)
"
clrSelect
>
@for (opr of query.operators; track opr) {
<option>{{ opr }}</option>
}
</select>
</clr-select-container>
</div>
<!-- VALUE -->
<div
*ngVar="
query.ddtype === 'DATE' ||
query.ddtype === 'DATETIME' ||
query.ddtype === 'TIME' as isDateTime
<button
(click)="
currentQueryIndex = queryIndex;
currentClauseIndex = clauseIndex
"
class="value-col form-group clr-col-md-3"
type="button"
class="btn btn-link"
>
@if (query.operator === 'IN' || query.operator === 'NOT IN') {
<div class="checkbox-vals">
<button
(click)="
currentQueryIndex = queryIndex;
currentClauseIndex = clauseIndex
"
type="button"
class="btn btn-link"
>
Choose values
</button>
<ng-container
*ngTemplateOutlet="
checkboxValues;
context: {
query: query,
queryIndex: queryIndex,
clauseIndex: clauseIndex
}
"
>
</ng-container>
</div>
}
@if (
query.operator !== 'BETWEEN' &&
query.operator !== 'IN' &&
query.operator !== 'NOT IN' &&
query.operator !== 'LIKE' &&
query.operator !== 'CONTAINS' &&
query.operator !== 'BEGINS_WITH'
) {
<div class="single-field-vals">
<ng-container
*ngTemplateOutlet="
isDateTime && usePickers ? picker : notPicker;
context: {
query: query,
queryIndex: queryIndex,
clauseIndex: clauseIndex,
isDateTime: isDateTime
}
"
>
<!-- Based on check above, here correct ng-template will be put (picker or dropdown) -->
</ng-container>
</div>
}
@if (query.operator === 'BETWEEN') {
<div class="range-vals">
<div class="from">
<ng-container
*ngTemplateOutlet="
isDateTime && usePickers ? picker : notPickerRange;
context: {
range: 'start',
query: query,
queryValueIndex: 0,
queryIndex: queryIndex,
clauseIndex: clauseIndex,
isDateTime: isDateTime
}
"
>
<!-- Based on check above, here correct ng-template will be put (picker or dropdown) -->
</ng-container>
</div>
<div class="to">
<ng-container
*ngTemplateOutlet="
isDateTime && usePickers ? picker : notPickerRange;
context: {
range: 'end',
query: query,
queryValueIndex: 1,
queryIndex: queryIndex,
clauseIndex: clauseIndex,
isDateTime: isDateTime
}
"
>
<!-- Based on check above, here correct ng-template will be put (picker or dropdown) -->
</ng-container>
</div>
</div>
}
@if (
query.operator === 'LIKE' ||
query.operator === 'BEGINS_WITH' ||
query.operator === 'CONTAINS'
) {
<div class="contains-vals">
<label class="clr-control-label">Value</label>
<input
class="input-val"
type="text"
[(ngModel)]="query.value"
(ngModelChange)="
setVariableValues($event, queryIndex, clauseIndex)
"
/>
</div>
}
</div>
<div class="clause-buttons clr-col-md-2 btn-group">
<button
class="btn btn-warning btn-block"
(click)="removeClause(queryIndex, clauseIndex)"
[disabled]="
clauses.queryObj[clauseIndex].elements.length === 1
Choose values
</button>
<ng-container
*ngTemplateOutlet="
checkboxValues;
context: {
query: query,
queryIndex: queryIndex,
clauseIndex: clauseIndex
}
"
>
</ng-container>
</div>
<div
*ngIf="
query.operator !== 'BETWEEN' &&
query.operator !== 'IN' &&
query.operator !== 'NOT IN' &&
query.operator !== 'LIKE' &&
query.operator !== 'CONTAINS' &&
query.operator !== 'BEGINS_WITH'
"
class="single-field-vals"
>
<ng-container
*ngTemplateOutlet="
isDateTime && usePickers ? picker : notPicker;
context: {
query: query,
queryIndex: queryIndex,
clauseIndex: clauseIndex,
isDateTime: isDateTime
}
"
>
<!-- Based on check above, here correct ng-template will be put (picker or dropdown) -->
</ng-container>
</div>
<div *ngIf="query.operator === 'BETWEEN'" class="range-vals">
<div class="from">
<ng-container
*ngTemplateOutlet="
isDateTime && usePickers ? picker : notPickerRange;
context: {
range: 'start',
query: query,
queryValueIndex: 0,
queryIndex: queryIndex,
clauseIndex: clauseIndex,
isDateTime: isDateTime
}
"
>
<clr-icon shape="minus"></clr-icon>
<span></span>
</button>
<button
class="btn btn-success btn-block"
(click)="addClause(clauseIndex)"
<!-- Based on check above, here correct ng-template will be put (picker or dropdown) -->
</ng-container>
</div>
<div class="to">
<ng-container
*ngTemplateOutlet="
isDateTime && usePickers ? picker : notPickerRange;
context: {
range: 'end',
query: query,
queryValueIndex: 1,
queryIndex: queryIndex,
clauseIndex: clauseIndex,
isDateTime: isDateTime
}
"
>
<clr-icon shape="plus"></clr-icon>
<span></span>
</button>
<!-- Based on check above, here correct ng-template will be put (picker or dropdown) -->
</ng-container>
</div>
</div>
}
<div
*ngIf="
query.operator === 'LIKE' ||
query.operator === 'BEGINS_WITH' ||
query.operator === 'CONTAINS'
"
class="contains-vals"
>
<label class="clr-control-label">Value</label>
<input
class="input-val"
type="text"
[(ngModel)]="query.value"
(ngModelChange)="
setVariableValues($event, queryIndex, clauseIndex)
"
/>
</div>
</div>
<div class="clause-buttons clr-col-md-2 btn-group">
<button
class="btn btn-warning btn-block"
(click)="removeClause(queryIndex, clauseIndex)"
[disabled]="clauses.queryObj[clauseIndex].elements.length === 1"
>
<clr-icon shape="minus"></clr-icon>
<span></span>
</button>
<button
class="btn btn-success btn-block"
(click)="addClause(clauseIndex)"
>
<clr-icon shape="plus"></clr-icon>
<span></span>
</button>
</div>
</div>
</div>
<br />
</div>
}
<br />
</div>
</div>
</div>
@@ -307,8 +305,8 @@
let-queryIndex="queryIndex"
let-clauseIndex="clauseIndex"
>
@switch (query.ddtype) {
@case ('DATE') {
<ng-container [ngSwitch]="query.ddtype">
<ng-container *ngSwitchCase="'DATE'">
<app-soft-select
label="Value"
type="date"
@@ -325,13 +323,14 @@
>
<!-- In case we want to enable dropdown on the pickers, uncomment below -->
<!-- <ng-container *ngFor="let value of query.values">
<option *ngIf="value.unformatted !== null">
{{ value.formatted | dateTimeFormatter: 'date' }}
</option>
</ng-container> -->
<option *ngIf="value.unformatted !== null">
{{ value.formatted | dateTimeFormatter: 'date' }}
</option>
</ng-container> -->
</app-soft-select>
}
@case ('DATETIME') {
</ng-container>
<ng-container *ngSwitchCase="'DATETIME'">
<app-soft-select
label="Value"
type="date"
@@ -349,11 +348,12 @@
>
<!-- In case we want to enable dropdown on the pickers, uncomment below -->
<!-- <ng-container *ngFor="let value of query.values">
<option *ngIf="value.unformatted !== null">
{{ value.formatted | dateTimeFormatter: 'date' }}
</option>
</ng-container> -->
<option *ngIf="value.unformatted !== null">
{{ value.formatted | dateTimeFormatter: 'date' }}
</option>
</ng-container> -->
</app-soft-select>
<app-soft-select
type="time"
[disableSoftselect]="true"
@@ -370,13 +370,14 @@
>
<!-- In case we want to enable dropdown on the pickers, uncomment below -->
<!-- <ng-container *ngFor="let value of query.values">
<option *ngIf="value.unformatted !== null">
{{ value.formatted | dateTimeFormatter: 'time' }}
</option>
</ng-container> -->
<option *ngIf="value.unformatted !== null">
{{ value.formatted | dateTimeFormatter: 'time' }}
</option>
</ng-container> -->
</app-soft-select>
}
@case ('TIME') {
</ng-container>
<ng-container *ngSwitchCase="'TIME'">
<app-soft-select
label="Value"
type="time"
@@ -393,13 +394,13 @@
>
<!-- In case we want to enable dropdown on the pickers, uncomment below -->
<!-- <ng-container *ngFor="let value of query.values">
<option *ngIf="value.unformatted !== null">
{{ value.formatted | dateTimeFormatter: 'time' }}
</option>
</ng-container> -->
<option *ngIf="value.unformatted !== null">
{{ value.formatted | dateTimeFormatter: 'time' }}
</option>
</ng-container> -->
</app-soft-select>
}
}
</ng-container>
</ng-container>
</ng-template>
<ng-template
@@ -424,27 +425,19 @@
onAutocompleteLoadingMore($event, query.variable, queryIndex, clauseIndex)
"
>
@if (!query.valueVariable) {
<div>
@for (column of query.values; track column) {
<option [value]="column.unformatted">
{{ column.formatted.trim() }}
</option>
}
</div>
}
<div *ngIf="!query.valueVariable">
<option [value]="column.unformatted" *ngFor="let column of query.values">
{{ column.formatted.trim() }}
</option>
</div>
@if (query.valueVariable) {
<div>
@for (column of cols; track column) {
@if (column.TYPE === query.type) {
<option [value]="column.NAME">
{{ column.NAME }}
</option>
}
}
</div>
}
<div *ngIf="query.valueVariable">
<ng-container *ngFor="let column of cols">
<option [value]="column.NAME" *ngIf="column.TYPE === query.type">
{{ column.NAME }}
</option>
</ng-container>
</div>
</app-soft-select>
</ng-template>
@@ -473,11 +466,9 @@
: false
"
>
@for (column of query.values; track column) {
<option [value]="column.formatted">
{{ column.formatted }}
</option>
}
<option [value]="column.formatted" *ngFor="let column of query.values">
{{ column.formatted }}
</option>
</app-soft-select>
</ng-template>
@@ -496,31 +487,30 @@
>
<h3 class="modal-title">Select values</h3>
<div class="modal-body">
@if (!isArr(query.value)) {
<h5 class="no-values">No values available.</h5>
}
<h5 *ngIf="!isArr(query.value)" class="no-values">
No values available.
</h5>
@if (isArr(query.value)) {
<section class="form-block">
<clr-checkbox-container>
@for (column of query.values; track column; let i = $index) {
<clr-checkbox-wrapper>
<input
type="checkbox"
clrCheckbox
[(ngModel)]="query.value[i].checked"
(ngModelChange)="
setVariableValues($event, queryIndex, clauseIndex)
"
/>
<label>
{{ column.formatted }}
</label>
</clr-checkbox-wrapper>
}
</clr-checkbox-container>
</section>
}
<section class="form-block" *ngIf="isArr(query.value)">
<clr-checkbox-container>
<clr-checkbox-wrapper
*ngFor="let column of query.values; let i = index"
>
<input
type="checkbox"
clrCheckbox
[(ngModel)]="query.value[i].checked"
(ngModelChange)="
setVariableValues($event, queryIndex, clauseIndex)
"
/>
<label>
{{ column.formatted }}
</label>
</clr-checkbox-wrapper>
</clr-checkbox-container>
</section>
</div>
<div class="modal-footer">
<button
+37 -11
View File
@@ -22,7 +22,6 @@ import { QueryDateTime } from './models/QueryDateTime.model'
import { isSpecialMissing } from '@sasjs/utils/input/validators'
import { get } from 'lodash-es'
import { OnLoadingMoreEvent } from '../shared/autocomplete/autocomplete.component'
import { getFilterObjPath } from './utils/getFilterObjPath'
registerLocaleData(localeEnGB)
@Component({
@@ -252,7 +251,17 @@ export class QueryComponent
public setToGlobals() {
if (!this.caching) return
const objPath = getFilterObjPath(globals.rootParam, this.viewboxId)
let objPath = ''
if (globals.rootParam === 'home' || globals.rootParam === 'editor') {
if (this.viewboxId) {
objPath = `viewboxes.${this.viewboxId}`
} else {
objPath = 'editor'
}
} else if (globals.rootParam === 'view') {
objPath = 'viewer'
}
get(globals, objPath).filter.groupLogic = this.groupLogic
if (typeof this.whereClause === 'string') {
@@ -271,7 +280,17 @@ export class QueryComponent
public getFromGlobals() {
if (!this.caching) return
const objPath = getFilterObjPath(globals.rootParam, this.viewboxId)
let objPath = ''
if (globals.rootParam === 'home' || globals.rootParam === 'editor') {
if (this.viewboxId) {
objPath = `viewboxes.${this.viewboxId}`
} else {
objPath = 'editor'
}
} else if (globals.rootParam === 'view') {
objPath = 'viewer'
}
if (get(globals, objPath).filter.cols.length > 0) {
this.cols = JSON.parse(JSON.stringify(get(globals, objPath).filter.cols))
@@ -1032,23 +1051,22 @@ export class QueryComponent
this.columnsSub = this.sasStoreService.columns.subscribe(
(response: any) => {
let cols = response.data.cols
const objPath = getFilterObjPath(globals.rootParam, this.viewboxId)
if (globals.rootParam === 'home' || globals.rootParam === 'editor') {
this.cols = cols
let some = cols[0].NAME
this.libds = response.libds
get(globals, objPath).filter.cols = JSON.parse(JSON.stringify(cols))
globals.editor.filter.cols = JSON.parse(JSON.stringify(cols))
}
if (globals.rootParam === 'view') {
if (get(globals, objPath).filter.cols.length < 1) {
if (globals.viewer.filter.cols.length < 1) {
this.cols = cols
let some = cols[0].NAME
this.libds = response.libds
get(globals, objPath).filter.cols = JSON.parse(JSON.stringify(cols))
globals.viewer.filter.cols = JSON.parse(JSON.stringify(cols))
}
}
@@ -1059,12 +1077,20 @@ export class QueryComponent
)
this.valuesSub = this.sasStoreService.values.subscribe((res: any) => {
const objPath = getFilterObjPath(globals.rootParam, this.viewboxId)
if (globals.rootParam === 'home' || globals.rootParam === 'editor') {
if (globals.editor.filter.vals.length < 1) {
this.vals = res.vals
if (get(globals, objPath).filter.vals.length < 1) {
this.vals = res.vals
globals.editor.filter.vals = JSON.parse(JSON.stringify(res.vals))
}
}
get(globals, objPath).filter.vals = JSON.parse(JSON.stringify(res.vals))
if (globals.rootParam === 'view') {
if (globals.viewer.filter.vals.length < 1) {
this.vals = res.vals
globals.viewer.filter.vals = JSON.parse(JSON.stringify(res.vals))
}
}
})
}
@@ -1,31 +0,0 @@
import { getFilterObjPath } from './getFilterObjPath'
describe('getFilterObjPath', () => {
it("resolves to 'viewer' for the view page with no viewboxId (existing correct case)", () => {
expect(getFilterObjPath('view', undefined)).toEqual('viewer')
})
it("resolves to 'editor' for the editor page with no viewboxId", () => {
expect(getFilterObjPath('editor', undefined)).toEqual('editor')
})
it("resolves to 'editor' for the home page with no viewboxId (existing home/editor grouping)", () => {
expect(getFilterObjPath('home', undefined)).toEqual('editor')
})
it("resolves to 'viewboxes.<id>' when viewboxId is set on the view page - the actual bug (previously returned 'viewer')", () => {
expect(getFilterObjPath('view', 42)).toEqual('viewboxes.42')
})
it("resolves to 'viewboxes.<id>' when viewboxId is set on the editor page (already correct today)", () => {
expect(getFilterObjPath('editor', 42)).toEqual('viewboxes.42')
})
it("resolves to 'viewboxes.<id>' when viewboxId is set on the home page (already correct today)", () => {
expect(getFilterObjPath('home', 42)).toEqual('viewboxes.42')
})
it('resolves to an empty string for an unrecognized rootParam (matches existing fallthrough)', () => {
expect(getFilterObjPath('', undefined)).toEqual('')
})
})
@@ -1,15 +0,0 @@
/**
* Resolves which bucket of the `globals` filter cache (_globals.ts) a
* QueryComponent instance should read/write. viewboxId is checked first,
* unconditionally.
*/
export const getFilterObjPath = (
rootParam: string,
viewboxId?: number
): string => {
if (viewboxId) return `viewboxes.${viewboxId}`
if (rootParam === 'home' || rootParam === 'editor') return 'editor'
if (rootParam === 'view') return 'viewer'
return ''
}
File diff suppressed because it is too large Load Diff
@@ -1,161 +1,148 @@
<div class="content-area">
<div class="card">
@if (remained === 0) {
<div class="d-flex justify-content-center">
<div class="card-block noapprovals-info-wrapper">
<clr-icon
shape="warning-standard"
size="60"
class="is-info icon-dc-fill"
></clr-icon>
<h3 class="text-center color-gray">
There are no approvals remaining
</h3>
</div>
<div *ngIf="remained === 0" class="d-flex justify-content-center">
<div class="card-block noapprovals-info-wrapper">
<clr-icon
shape="warning-standard"
size="60"
class="is-info icon-dc-fill"
></clr-icon>
<h3 class="text-center color-gray">There are no approvals remaining</h3>
</div>
}
<div class="card-header" [ngClass]="{ noBorder: !loaded }">
@if (loaded && remained !== 0) {
<h3 class="center clr-col-md-12 text-center">REVIEW</h3>
}
@if (loaded && remained !== 0) {
<p class="text-center font-weight-700 color-dark-gray">
You have <span>{{ remained }} </span>approvals remaining
</p>
}
</div>
@if (!loaded) {
<div class="approvals-list-wrapper">
@if (!loaded) {
<span class="spinner"> Loading... </span>
}
@if (!loaded) {
<div>
<h3>Loading approvals list</h3>
</div>
}
<div class="card-header" [ngClass]="{ noBorder: !loaded }">
<h3
class="center clr-col-md-12 text-center"
*ngIf="loaded && remained !== 0"
>
REVIEW
</h3>
<p
class="text-center font-weight-700 color-dark-gray"
*ngIf="loaded && remained !== 0"
>
You have <span>{{ remained }} </span>approvals remaining
</p>
</div>
<div *ngIf="!loaded" class="approvals-list-wrapper">
<span class="spinner" *ngIf="!loaded"> Loading... </span>
<div *ngIf="!loaded">
<h3>Loading approvals list</h3>
</div>
}
</div>
<div class="clr-col-md-12" ng-if="loaded">
@if (approveList && remained !== 0) {
<div>
<clr-datagrid class="datagrid-compact datagrid-custom-footer">
<clr-dg-column [clrDgField]="'submitter'">
SUBMITTER
<clr-dg-string-filter
[clrDgStringFilter]="submitterFilter"
aria-label="Filter submitter"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column [clrDgField]="'baseTable'">
BASE TABLE
<clr-dg-string-filter
[clrDgStringFilter]="baseTableFilter"
aria-label="Filter base table"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column [clrDgField]="'submitted'">
SUBMITTED
<clr-dg-string-filter
[clrDgStringFilter]="submittedFilter"
aria-label="Filter submitted date"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column [clrDgField]="'submitReason'">
SUBMIT REASON
<clr-dg-string-filter
[clrDgStringFilter]="submitReasonFilter"
aria-label="Filter submit reason"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column>ACTION</clr-dg-column>
<clr-dg-column>DOWNLOAD</clr-dg-column>
<clr-dg-row
*clrDgItems="let approveItem of approveList; let i = index"
>
<clr-dg-cell>{{ approveItem.submitter }}</clr-dg-cell>
<clr-dg-cell>{{ approveItem.baseTable }}</clr-dg-cell>
<clr-dg-cell>{{ approveItem.submitted }}</clr-dg-cell>
<clr-dg-cell>{{ approveItem.submitReason }}</clr-dg-cell>
<clr-dg-cell>
<div
class="clr-row d-flex justify-content-around"
role="toolbar"
aria-label="Table actions"
<div *ngIf="approveList && remained !== 0">
<clr-datagrid class="datagrid-compact datagrid-custom-footer">
<clr-dg-column [clrDgField]="'submitter'">
SUBMITTER
<clr-dg-string-filter
[clrDgStringFilter]="submitterFilter"
aria-label="Filter submitter"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column [clrDgField]="'baseTable'">
BASE TABLE
<clr-dg-string-filter
[clrDgStringFilter]="baseTableFilter"
aria-label="Filter base table"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column [clrDgField]="'submitted'">
SUBMITTED
<clr-dg-string-filter
[clrDgStringFilter]="submittedFilter"
aria-label="Filter submitted date"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column [clrDgField]="'submitReason'">
SUBMIT REASON
<clr-dg-string-filter
[clrDgStringFilter]="submitReasonFilter"
aria-label="Filter submit reason"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column>ACTION</clr-dg-column>
<clr-dg-column>DOWNLOAD</clr-dg-column>
<clr-dg-row
*clrDgItems="let approveItem of approveList; let i = index"
>
<clr-dg-cell>{{ approveItem.submitter }}</clr-dg-cell>
<clr-dg-cell>{{ approveItem.baseTable }}</clr-dg-cell>
<clr-dg-cell>{{ approveItem.submitted }}</clr-dg-cell>
<clr-dg-cell>{{ approveItem.submitReason }}</clr-dg-cell>
<clr-dg-cell>
<div
class="clr-row d-flex justify-content-around"
role="toolbar"
aria-label="Table actions"
>
<a
class="column-center links tooltip tooltip-md tooltip-bottom-left color-green"
(click)="getClicked(i)"
>
<a
class="column-center links tooltip tooltip-md tooltip-bottom-left color-green"
(click)="getClicked(i)"
>
<clr-icon
shape="check"
size="24"
aria-hidden="true"
></clr-icon>
<span class="tooltip-content"
>Go to review page screen</span
>
</a>
<a
class="column-center links tooltip tooltip-md tooltip-bottom-left color-red"
(click)="!approveItem.rejectLoading ? rejecting(i) : ''"
>
@if (!approveItem.rejectLoading) {
<clr-icon
shape="ban"
size="22"
aria-hidden="true"
></clr-icon>
}
@if (approveItem.rejectLoading) {
<clr-spinner
[clrSmall]="true"
aria-hidden="true"
></clr-spinner>
}
<span class="tooltip-content">Reject</span>
</a>
<a
class="column-center links tooltip tooltip-md tooltip-bottom-left color-blue"
(click)="getTable(approveItem.tableId)"
>
<clr-icon
shape="code"
size="28"
aria-hidden="true"
></clr-icon>
<span class="tooltip-content"
>Go to staged data screen</span
>
</a>
</div>
</clr-dg-cell>
<clr-dg-cell class="p-0 d-flex justify-content-center">
<button
class="btn btn-success"
aria-label="Download audit file"
[id]="approveItem.tableId"
(click)="
download(approveItem.tableId); $event.stopPropagation()
"
<clr-icon
shape="check"
size="24"
aria-hidden="true"
></clr-icon>
<span class="tooltip-content">Go to review page screen</span>
</a>
<a
class="column-center links tooltip tooltip-md tooltip-bottom-left color-red"
(click)="!approveItem.rejectLoading ? rejecting(i) : ''"
>
<clr-icon shape="download"></clr-icon>
</button>
</clr-dg-cell>
</clr-dg-row>
<clr-dg-footer>
<clr-dg-pagination #pagination [clrDgPageSize]="10">
<clr-dg-page-size [clrPageSizeOptions]="[3, 5, 10, 15]"
>Items per page</clr-dg-page-size
<clr-icon
*ngIf="!approveItem.rejectLoading"
shape="ban"
size="22"
aria-hidden="true"
></clr-icon>
<clr-spinner
*ngIf="approveItem.rejectLoading"
[clrSmall]="true"
aria-hidden="true"
></clr-spinner>
<span class="tooltip-content">Reject</span>
</a>
<a
class="column-center links tooltip tooltip-md tooltip-bottom-left color-blue"
(click)="getTable(approveItem.tableId)"
>
{{ pagination.firstItem + 1 }} -
{{ pagination.lastItem + 1 }} of
{{ pagination.totalItems }} approvals
</clr-dg-pagination>
</clr-dg-footer>
</clr-datagrid>
</div>
}
<clr-icon
shape="code"
size="28"
aria-hidden="true"
></clr-icon>
<span class="tooltip-content">Go to staged data screen</span>
</a>
</div>
</clr-dg-cell>
<clr-dg-cell class="p-0 d-flex justify-content-center">
<button
class="btn btn-success"
aria-label="Download audit file"
[id]="approveItem.tableId"
(click)="
download(approveItem.tableId); $event.stopPropagation()
"
>
<clr-icon shape="download"></clr-icon>
</button>
</clr-dg-cell>
</clr-dg-row>
<clr-dg-footer>
<clr-dg-pagination #pagination [clrDgPageSize]="10">
<clr-dg-page-size [clrPageSizeOptions]="[3, 5, 10, 15]"
>Items per page</clr-dg-page-size
>
{{ pagination.firstItem + 1 }} - {{ pagination.lastItem + 1 }} of
{{ pagination.totalItems }} approvals
</clr-dg-pagination>
</clr-dg-footer>
</clr-datagrid>
</div>
</div>
</div>
</div>
@@ -1,13 +1,12 @@
<div class="content-area">
@if (noData) {
<div
id="noDataContainer"
class="card-block d-flex justify-content-center flex-column align-items-center"
>
<clr-icon shape="warning-standard" size="60" class="is-info"></clr-icon>
<h3 class="text-center color-gray">There is no history to show</h3>
</div>
}
<div
*ngIf="noData"
id="noDataContainer"
class="card-block d-flex justify-content-center flex-column align-items-center"
>
<clr-icon shape="warning-standard" size="60" class="is-info"></clr-icon>
<h3 class="text-center color-gray">There is no history to show</h3>
</div>
<clr-modal [(clrModalOpen)]="openModal" [clrModalSize]="'xl'">
<h4 class="modal-title">Approval details</h4>
<div class="modal-body">
@@ -19,38 +18,31 @@
</tr>
</thead>
<tbody>
@for (col of tableTitles; track col; let ind = $index) {
<tr>
<td class="left">{{ col }}</td>
<td class="left">
@if (ind < 1) {
<a
(click)="getTable(approveData[col])"
class="cursor-pointer table-link"
>{{ approveData[col] }}</a
>
}
@if (ind < 2 && ind >= 1) {
<div>
<a
(click)="getBaseTable(approveData[col])"
class="cursor-pointer table-link"
>VIEW</a
>
<span> / </span>
<a
(click)="getEditTable(approveData[col])"
class="cursor-pointer table-link"
>EDIT</a
>
</div>
}
@if (ind >= 2) {
<span>{{ approveData[col] }}</span>
}
</td>
</tr>
}
<tr *ngFor="let col of tableTitles; let ind = index">
<td class="left">{{ col }}</td>
<td class="left">
<a
*ngIf="ind < 1"
(click)="getTable(approveData[col])"
class="cursor-pointer table-link"
>{{ approveData[col] }}</a
>
<div *ngIf="ind < 2 && ind >= 1">
<a
(click)="getBaseTable(approveData[col])"
class="cursor-pointer table-link"
>VIEW</a
>
<span> / </span>
<a
(click)="getEditTable(approveData[col])"
class="cursor-pointer table-link"
>EDIT</a
>
</div>
<span *ngIf="ind >= 2">{{ approveData[col] }}</span>
</td>
</tr>
</tbody>
</table>
</div>
@@ -66,159 +58,148 @@
</div>
</clr-modal>
@if (!loaded) {
<div
class="h-70vh d-flex justify-content-center flex-column align-items-center"
>
@if (!loaded) {
<span class="spinner"> Loading... </span>
}
@if (!loaded) {
<div>
<h3>Loading history</h3>
</div>
}
<div
*ngIf="!loaded"
class="h-70vh d-flex justify-content-center flex-column align-items-center"
>
<span class="spinner" *ngIf="!loaded"> Loading... </span>
<div *ngIf="!loaded">
<h3>Loading history</h3>
</div>
}
</div>
@if (!noData && loaded) {
<div class="card">
<div class="card-header">
@if (loaded) {
<h3 class="center clr-col-md-12 text-center">HISTORY</h3>
}
@if (licenceState.value.history_rows_allowed !== Infinity) {
<p class="text-center font-weight-700 color-dark-gray">
To unlock more than
{{ licenceState.value.history_rows_allowed }} records, contact
support&#64;datacontroller.io
</p>
}
</div>
@if (loaded) {
<clr-datagrid class="datagrid-history datagrid-custom-footer">
<clr-dg-column [clrDgField]="'basetable'">
BASE_TABLE
<clr-dg-string-filter
[clrDgStringFilter]="baseTableFilter"
aria-label="Filter base table"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column [clrDgField]="'status'">
STATUS
<clr-dg-string-filter
[clrDgStringFilter]="statusFilter"
aria-label="Filter status"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column [clrDgField]="'submitter'">
SUBMITTER
<clr-dg-string-filter
[clrDgStringFilter]="submitterFilter"
aria-label="Filter submitter"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column [clrDgField]="'submittedReason'">
SUBMIT REASON
<clr-dg-string-filter
[clrDgStringFilter]="submitReasonFilter"
aria-label="Filter submit reason"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column [clrDgField]="'submitted'">
SUBMITTED
<clr-dg-string-filter
[clrDgStringFilter]="submittedFilter"
aria-label="Filter submitted date"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column [clrDgField]="'reviewed'">
APPROVED / REJECTED
<clr-dg-string-filter
[clrDgStringFilter]="reviewedFilter"
aria-label="Filter reviewed date"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column>DOWNLOAD</clr-dg-column>
<clr-dg-row
*clrDgItems="let historyItem of history"
(click)="getApprIndex(historyItem)"
>
<clr-dg-cell class="verCenter">
<a
class="btn btn-sm btn-link m-0"
(click)="getBaseTable(historyItem.basetable)"
>{{ historyItem.basetable }}</a
>
</clr-dg-cell>
<clr-dg-cell
class="verCenter"
[ngClass]="{
rejected: historyItem.status === 'REJECTED',
accepted: historyItem.status === 'APPROVED'
}"
>{{ historyItem.status }}</clr-dg-cell
>
<clr-dg-cell class="verCenter">{{
historyItem.submitter
}}</clr-dg-cell>
<clr-dg-cell class="verCenter">{{
historyItem.submittedReason
}}</clr-dg-cell>
<clr-dg-cell class="verCenter">{{
historyItem.submitted
}}</clr-dg-cell>
<clr-dg-cell class="verCenter">{{
historyItem.reviewed
}}</clr-dg-cell>
<clr-dg-cell class="verCenter p-0 d-flex justify-content-center">
<button
aria-label="Download audit file"
class="btn btn-success"
(click)="
download(historyItem.tableId); $event.stopPropagation()
"
>
<clr-icon shape="download"></clr-icon>
</button>
</clr-dg-cell>
</clr-dg-row>
<!-- Let's keep this part if in future we decide to do the paging instead of `load more` approach -->
<!-- <clr-dg-footer class="d-flex justify-content-start">
<span>items per page</span>
<select class="mx-5" [(ngModel)]="itemsNum">
<option [ngValue]="3">3</option>
<option [ngValue]="5">5</option>
<option [ngValue]="10">10</option>
<option [ngValue]="15">15</option>
</select>
<clr-dg-pagination
#pagination
[clrDgPageSize]="itemsNum"
class="center"
>
{{ pagination.firstItem + 1 }} - {{ pagination.lastItem + 1 }} of
{{ pagination.totalItems }} updates
</clr-dg-pagination>
</clr-dg-footer> -->
</clr-datagrid>
}
<div
class="load-more d-flex clr-justify-content-center clr-align-items-center"
<div *ngIf="!noData && loaded" class="card">
<div class="card-header">
<h3 class="center clr-col-md-12 text-center" *ngIf="loaded">HISTORY</h3>
<p
class="text-center font-weight-700 color-dark-gray"
*ngIf="licenceState.value.history_rows_allowed !== Infinity"
>
@if (
To unlock more than
{{ licenceState.value.history_rows_allowed }} records, contact
support&#64;datacontroller.io
</p>
</div>
<clr-datagrid
class="datagrid-history datagrid-custom-footer"
*ngIf="loaded"
>
<clr-dg-column [clrDgField]="'basetable'">
BASE_TABLE
<clr-dg-string-filter
[clrDgStringFilter]="baseTableFilter"
aria-label="Filter base table"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column [clrDgField]="'status'">
STATUS
<clr-dg-string-filter
[clrDgStringFilter]="statusFilter"
aria-label="Filter status"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column [clrDgField]="'submitter'">
SUBMITTER
<clr-dg-string-filter
[clrDgStringFilter]="submitterFilter"
aria-label="Filter submitter"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column [clrDgField]="'submittedReason'">
SUBMIT REASON
<clr-dg-string-filter
[clrDgStringFilter]="submitReasonFilter"
aria-label="Filter submit reason"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column [clrDgField]="'submitted'">
SUBMITTED
<clr-dg-string-filter
[clrDgStringFilter]="submittedFilter"
aria-label="Filter submitted date"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column [clrDgField]="'reviewed'">
APPROVED / REJECTED
<clr-dg-string-filter
[clrDgStringFilter]="reviewedFilter"
aria-label="Filter reviewed date"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column>DOWNLOAD</clr-dg-column>
<clr-dg-row
*clrDgItems="let historyItem of history"
(click)="getApprIndex(historyItem)"
>
<clr-dg-cell class="verCenter">
<a
class="btn btn-sm btn-link m-0"
(click)="getBaseTable(historyItem.basetable)"
>{{ historyItem.basetable }}</a
>
</clr-dg-cell>
<clr-dg-cell
class="verCenter"
[ngClass]="{
rejected: historyItem.status === 'REJECTED',
accepted: historyItem.status === 'APPROVED'
}"
>{{ historyItem.status }}</clr-dg-cell
>
<clr-dg-cell class="verCenter">{{ historyItem.submitter }}</clr-dg-cell>
<clr-dg-cell class="verCenter">{{
historyItem.submittedReason
}}</clr-dg-cell>
<clr-dg-cell class="verCenter">{{ historyItem.submitted }}</clr-dg-cell>
<clr-dg-cell class="verCenter">{{ historyItem.reviewed }}</clr-dg-cell>
<clr-dg-cell class="verCenter p-0 d-flex justify-content-center">
<button
aria-label="Download audit file"
class="btn btn-success"
(click)="download(historyItem.tableId); $event.stopPropagation()"
>
<clr-icon shape="download"></clr-icon>
</button>
</clr-dg-cell>
</clr-dg-row>
<!-- Let's keep this part if in future we decide to do the paging instead of `load more` approach -->
<!-- <clr-dg-footer class="d-flex justify-content-start">
<span>items per page</span>
<select class="mx-5" [(ngModel)]="itemsNum">
<option [ngValue]="3">3</option>
<option [ngValue]="5">5</option>
<option [ngValue]="10">10</option>
<option [ngValue]="15">15</option>
</select>
<clr-dg-pagination
#pagination
[clrDgPageSize]="itemsNum"
class="center"
>
{{ pagination.firstItem + 1 }} - {{ pagination.lastItem + 1 }} of
{{ pagination.totalItems }} updates
</clr-dg-pagination>
</clr-dg-footer> -->
</clr-datagrid>
<div
class="load-more d-flex clr-justify-content-center clr-align-items-center"
>
<button
*ngIf="
this.licenceState.value.history_rows_allowed === Infinity &&
rowsLeftToLoad > 0
) {
<button
(click)="loadData()"
[clrLoading]="loadingMore"
class="btn btn-success"
>
Load {{ rowsLeftToLoad }} more
</button>
}
</div>
"
(click)="loadData()"
[clrLoading]="loadingMore"
class="btn btn-success"
>
Load {{ rowsLeftToLoad }} more
</button>
</div>
}
</div>
</div>
@@ -1,136 +1,123 @@
<div class="w-100">
@if (!subReady) {
<div class="content-area">
@if (!subReady) {
<div class="card">
@if (remained === 0 && loaded) {
<div class="d-flex justify-content-center">
<div
class="no-submitted-tables card-block d-flex justify-content-center flex-column align-items-center"
>
<clr-icon
shape="warning-standard"
size="60"
class="is-info"
></clr-icon>
<h3 class="text-center color-gray">
You have not submitted any tables
</h3>
</div>
</div>
}
<div class="card-header" [ngClass]="{ noBorder: !loaded }">
@if (remained !== 0) {
<h3 class="center clr-col-md-12 text-center">SUBMIT QUEUE</h3>
}
@if (loaded && remained !== 0) {
<p class="text-center font-weight-700 color-dark-gray">
You have <span>{{ remained }} </span>submissions waiting to be
approved
</p>
}
</div>
@if (!loaded) {
<div
class="h-70vh d-flex justify-content-center flex-column align-items-center"
>
@if (!loaded) {
<span class="spinner"> Loading... </span>
}
@if (!loaded) {
<div>
<h3>Loading submitted list</h3>
</div>
}
</div>
}
@if (loaded) {
<div class="clr-col-md-12">
@if (submitterList && remained !== 0) {
<div>
<clr-datagrid class="datagrid-compact datagrid-custom-footer">
<clr-dg-column>BASE TABLE</clr-dg-column>
<clr-dg-column [clrDgField]="'submitted'">
SUBMITTED
<clr-dg-string-filter
[clrDgStringFilter]="submittedFilter"
aria-label="Filter submitted date"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column [clrDgField]="'submitReason'">
SUBMIT REASON
<clr-dg-string-filter
[clrDgStringFilter]="submitReasonFilter"
aria-label="Filter submit reason"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column class="d-flex justify-content-center"
>ACTION</clr-dg-column
>
<clr-dg-column class="d-flex justify-content-center"
>DOWNLOAD</clr-dg-column
>
<clr-dg-row
*clrDgItems="let sub of submitterList; let i = index"
(click)="goToDetails(sub.tableId)"
class="cursor-pointer"
>
<clr-dg-cell>{{ sub.base }}</clr-dg-cell>
<clr-dg-cell>{{ sub.submitted }}</clr-dg-cell>
<!-- <clr-dg-cell>{{sub.approver}}</clr-dg-cell> -->
<clr-dg-cell>{{ sub.submitReason }}</clr-dg-cell>
<clr-dg-cell>
<div
class="row justify-content-around"
role="tooltip"
aria-label="Go to staged data screen"
>
<a
class="column-center links tooltip tooltip-md tooltip-bottom-left color-blue"
(click)="goToStage(sub.tableId)"
>
<clr-icon shape="code" size="28"></clr-icon>
<span class="tooltip-content"
>Go to staged data screen</span
>
</a>
</div>
</clr-dg-cell>
<clr-dg-cell class="p-0 d-flex justify-content-center">
<button
class="btn btn-success"
aria-label="Download audit file for table record"
(click)="
download(sub.tableId); $event.stopPropagation()
"
>
<clr-icon shape="download"></clr-icon>
</button>
</clr-dg-cell>
</clr-dg-row>
<clr-dg-footer>
<clr-dg-pagination #pagination [clrDgPageSize]="10">
<clr-dg-page-size [clrPageSizeOptions]="[3, 5, 10, 15]"
>Items per page</clr-dg-page-size
>
{{ pagination.firstItem + 1 }} -
{{ pagination.lastItem + 1 }} of
{{ pagination.totalItems }} submissions
</clr-dg-pagination>
</clr-dg-footer>
</clr-datagrid>
</div>
}
</div>
}
<div *ngIf="!subReady" class="content-area">
<div *ngIf="!subReady" class="card">
<div
*ngIf="remained === 0 && loaded"
class="d-flex justify-content-center"
>
<div
class="no-submitted-tables card-block d-flex justify-content-center flex-column align-items-center"
>
<clr-icon
shape="warning-standard"
size="60"
class="is-info"
></clr-icon>
<h3 class="text-center color-gray">
You have not submitted any tables
</h3>
</div>
}
</div>
}
</div>
<div class="card-header" [ngClass]="{ noBorder: !loaded }">
<h3 class="center clr-col-md-12 text-center" *ngIf="remained !== 0">
SUBMIT QUEUE
</h3>
<p
class="text-center font-weight-700 color-dark-gray"
*ngIf="loaded && remained !== 0"
>
You have <span>{{ remained }} </span>submissions waiting to be
approved
</p>
</div>
@if (subReady) {
<div>
<app-approve-details></app-approve-details>
<div
*ngIf="!loaded"
class="h-70vh d-flex justify-content-center flex-column align-items-center"
>
<span class="spinner" *ngIf="!loaded"> Loading... </span>
<div *ngIf="!loaded">
<h3>Loading submitted list</h3>
</div>
</div>
<div class="clr-col-md-12" *ngIf="loaded">
<div *ngIf="submitterList && remained !== 0">
<clr-datagrid class="datagrid-compact datagrid-custom-footer">
<clr-dg-column>BASE TABLE</clr-dg-column>
<clr-dg-column [clrDgField]="'submitted'">
SUBMITTED
<clr-dg-string-filter
[clrDgStringFilter]="submittedFilter"
aria-label="Filter submitted date"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column [clrDgField]="'submitReason'">
SUBMIT REASON
<clr-dg-string-filter
[clrDgStringFilter]="submitReasonFilter"
aria-label="Filter submit reason"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column class="d-flex justify-content-center"
>ACTION</clr-dg-column
>
<clr-dg-column class="d-flex justify-content-center"
>DOWNLOAD</clr-dg-column
>
<clr-dg-row
*clrDgItems="let sub of submitterList; let i = index"
(click)="goToDetails(sub.tableId)"
class="cursor-pointer"
>
<clr-dg-cell>{{ sub.base }}</clr-dg-cell>
<clr-dg-cell>{{ sub.submitted }}</clr-dg-cell>
<!-- <clr-dg-cell>{{sub.approver}}</clr-dg-cell> -->
<clr-dg-cell>{{ sub.submitReason }}</clr-dg-cell>
<clr-dg-cell>
<div
class="row justify-content-around"
role="tooltip"
aria-label="Go to staged data screen"
>
<a
class="column-center links tooltip tooltip-md tooltip-bottom-left color-blue"
(click)="goToStage(sub.tableId)"
>
<clr-icon shape="code" size="28"></clr-icon>
<span class="tooltip-content"
>Go to staged data screen</span
>
</a>
</div>
</clr-dg-cell>
<clr-dg-cell class="p-0 d-flex justify-content-center">
<button
class="btn btn-success"
aria-label="Download audit file for table record"
(click)="download(sub.tableId); $event.stopPropagation()"
>
<clr-icon shape="download"></clr-icon>
</button>
</clr-dg-cell>
</clr-dg-row>
<clr-dg-footer>
<clr-dg-pagination #pagination [clrDgPageSize]="10">
<clr-dg-page-size [clrPageSizeOptions]="[3, 5, 10, 15]"
>Items per page</clr-dg-page-size
>
{{ pagination.firstItem + 1 }} -
{{ pagination.lastItem + 1 }} of
{{ pagination.totalItems }} submissions
</clr-dg-pagination>
</clr-dg-footer>
</clr-datagrid>
</div>
</div>
</div>
}
</div>
<div *ngIf="subReady">
<app-approve-details></app-approve-details>
</div>
</div>
+116 -132
View File
@@ -1,146 +1,130 @@
<app-sidebar class="sidebar-height">
<clr-tree>
@if (roles) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchLibTreeInput
placeholder="Filter by Roles"
name="input"
[(ngModel)]="roleSearch"
(keyup)="roleListOnFilter()"
autocomplete="off"
/>
@if (searchLibTreeInput.value.length < 1) {
<clr-icon shape="search"></clr-icon>
}
@if (searchLibTreeInput.value.length > 0) {
<clr-icon
(click)="roleSearch = ''; roleListOnFilter()"
shape="times"
></clr-icon>
}
</div>
<clr-tree-node *ngIf="roles" class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchLibTreeInput
placeholder="Filter by Roles"
name="input"
[(ngModel)]="roleSearch"
(keyup)="roleListOnFilter()"
autocomplete="off"
/>
<clr-icon
*ngIf="searchLibTreeInput.value.length < 1"
shape="search"
></clr-icon>
<clr-icon
*ngIf="searchLibTreeInput.value.length > 0"
(click)="roleSearch = ''; roleListOnFilter()"
shape="times"
></clr-icon>
</div>
</clr-tree-node>
<ng-container *ngFor="let role of roles">
<clr-tree-node
(click)="roleOnClick(role)"
*ngIf="!role['hidden']"
[class.active]="role.ROLEURI === roleUri"
>
<p class="m-0 cursor-pointer list-padding">
<clr-icon shape="blocks-group"></clr-icon>
{{ role.ROLENAME }}
</p>
</clr-tree-node>
}
@for (role of roles; track role) {
@if (!role['hidden']) {
<clr-tree-node
(click)="roleOnClick(role)"
[class.active]="role.ROLEURI === roleUri"
>
<p class="m-0 cursor-pointer list-padding">
<clr-icon shape="blocks-group"></clr-icon>
{{ role.ROLENAME }}
</p>
</clr-tree-node>
}
}
</ng-container>
</clr-tree>
</app-sidebar>
<div class="content-area">
@if (loading) {
<div class="loadingSpinner">
<span class="spinner"> Loading... </span>
</div>
}
@if (roleMembers && !loading) {
<div>
<div class="clr-row">
<div class="clr-col-8">
<table class="table role-info">
<tbody>
<tr>
<td class="left">
<p class="role-info-text">
<b>{{ roleName }}</b>
</p>
</td>
</tr>
<tr>
<td class="left">
<i>{{ roleDesc }}</i>
</td>
</tr>
</tbody>
</table>
</div>
<div *ngIf="loading" class="loadingSpinner">
<span class="spinner"> Loading... </span>
</div>
<div *ngIf="roleMembers && !loading">
<div class="clr-row">
<div class="clr-col-8">
<table class="table role-info">
<tbody>
<tr>
<td class="left">
<p class="role-info-text">
<b>{{ roleName }}</b>
</p>
</td>
</tr>
<tr>
<td class="left">
<i>{{ roleDesc }}</i>
</td>
</tr>
</tbody>
</table>
</div>
<div class="clr-row">
<div class="clr-col-8">
<div class="card role-data">
<div>
<h3>MEMBERS ({{ roleMembersCount }})</h3>
@if (roleMembersCount == 0) {
<h5>No Members Present</h5>
}
<div class="table-container">
@if (roleMembersCount != 0) {
<table class="table member-table">
<thead>
<tr>
<td class="width-25"><b>NAME</b></td>
<td class="width-25"><b>EMAIL</b></td>
<td class="width-25"><b>CREATED</b></td>
<td class=""><b>UPDATED</b></td>
</tr>
</thead>
<tbody>
@for (member of roleMembers; track member) {
<tr
[routerLink]="'/view/usernav/users/' + member.URIMEM"
>
<td class="">{{ member.MEMBERNAME }}</td>
<td class="">{{ member.EMAIL }}</td>
<td class="">{{ member.MEMBERCREATED }}</td>
<td class="">{{ member.MEMBERUPDATED }}</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
<div class="clr-row">
<div class="clr-col-8">
<div class="card role-data">
<div>
<h3>MEMBERS ({{ roleMembersCount }})</h3>
<h5 *ngIf="roleMembersCount == 0">No Members Present</h5>
<div class="table-container">
<table *ngIf="roleMembersCount != 0" class="table member-table">
<thead>
<tr>
<td class="width-25"><b>NAME</b></td>
<td class="width-25"><b>EMAIL</b></td>
<td class="width-25"><b>CREATED</b></td>
<td class=""><b>UPDATED</b></td>
</tr>
</thead>
<tbody>
<tr
[routerLink]="'/view/usernav/users/' + member.URIMEM"
*ngFor="let member of roleMembers"
>
<td class="">{{ member.MEMBERNAME }}</td>
<td class="">{{ member.EMAIL }}</td>
<td class="">{{ member.MEMBERCREATED }}</td>
<td class="">{{ member.MEMBERUPDATED }}</td>
</tr>
</tbody>
</table>
</div>
<hr />
<div>
<h3>Groups ({{ roleGroupsCount }})</h3>
@if (roleGroupsCount == 0) {
<h5>No Groups Present !</h5>
}
<div class="table-container">
@if (roleGroupsCount != 0) {
<table class="table member-table">
<thead>
<tr>
<td class="width-25"><b>NAME</b></td>
<td class="width-25"><b>EMAIL</b></td>
<td class="width-25"><b>CREATED</b></td>
<td class=""><b>UPDATED</b></td>
</tr>
</thead>
<tbody>
@for (group of roleGroups; track group) {
<tr
[routerLink]="'/view/usernav/groups/' + group.URIMEM"
>
<td class="">{{ group.MEMBERNAME }}</td>
<td class="">{{ group.EMAIL }}</td>
<td class="">{{ group.MEMBERCREATED }}</td>
<td class="">{{ group.MEMBERUPDATED }}</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
<br />
</div>
<hr />
<div>
<h3>Groups ({{ roleGroupsCount }})</h3>
<h5 *ngIf="roleGroupsCount == 0">No Groups Present !</h5>
<div class="table-container">
<table *ngIf="roleGroupsCount != 0" class="table member-table">
<thead>
<tr>
<td class="width-25"><b>NAME</b></td>
<td class="width-25"><b>EMAIL</b></td>
<td class="width-25"><b>CREATED</b></td>
<td class=""><b>UPDATED</b></td>
</tr>
</thead>
<tbody>
<tr
[routerLink]="'/view/usernav/groups/' + group.URIMEM"
*ngFor="let group of roleGroups"
>
<td class="">{{ group.MEMBERNAME }}</td>
<td class="">{{ group.EMAIL }}</td>
<td class="">{{ group.MEMBERCREATED }}</td>
<td class="">{{ group.MEMBERUPDATED }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<br />
</div>
</div>
</div>
}
</div>
</div>
-141
View File
@@ -1,141 +0,0 @@
import { EventEmitter } from '@angular/core'
import { BehaviorSubject, Subject } from 'rxjs'
import { AppService } from './app.service'
/**
* AppService's constructor subscribes to several emitters/subjects (see
* subscribe() and the persistSelectedTheme check) and startUpData() touches
* a handful of methods on each collaborator these stubs cover exactly
* that surface, not the full real services (no TestBed/DI needed, same
* plain-instantiation-with-stubs precedent as va-filter.service.spec.ts).
*/
const buildDeps = () => {
const licenceService: any = {
isAppActivated: new BehaviorSubject<boolean | null>(null),
activation: jasmine.createSpy('activation').and.resolveTo(undefined)
}
const eventService: any = {
showInfoModal: jasmine.createSpy('showInfoModal'),
startupDataLoaded: jasmine.createSpy('startupDataLoaded'),
toggleDarkMode: jasmine.createSpy('toggleDarkMode')
}
const sasService: any = {
loadStartupServiceEmitter: new EventEmitter<any>(),
requestSiteIdEmitter: new EventEmitter<string>(),
request: jasmine.createSpy('request')
}
const loggerService: any = {
log: jasmine.createSpy('log')
}
const appSettingsService: any = {
settings: new BehaviorSubject({ persistSelectedTheme: false } as any)
}
const router: any = {
events: new Subject(),
url: '',
navigateByUrl: jasmine.createSpy('navigateByUrl')
}
const appStoreService: any = {
getDcAdapterSettings: () => undefined
}
return {
licenceService,
eventService,
sasService,
loggerService,
appSettingsService,
router,
appStoreService
}
}
const buildAppService = (deps: ReturnType<typeof buildDeps>) =>
new AppService(
deps.licenceService,
deps.eventService,
deps.sasService,
deps.loggerService,
deps.appSettingsService,
deps.router,
deps.appStoreService
)
// Minimal valid startupservice payload — just enough to pass startUpData()'s
// missing-props check (Globvars/Sasdatasets/Saslibs/XLMaps).
const validStartupResponse = () => ({
adapterResponse: {
SYSSITE: 'SITE1',
globvars: [{ ISADMIN: false, DC_ADMIN_GROUP: '', DCLIB: 'DCLIB' }],
sasdatasets: [],
saslibs: {},
xlmaps: []
}
})
describe('AppService - startup retry', () => {
it('retries once and succeeds without showing the error modal when only the first attempt rejects', async () => {
const deps = buildDeps()
deps.sasService.request.and.returnValues(
Promise.reject('timeout'),
Promise.resolve(validStartupResponse())
)
const appService = buildAppService(deps)
// Keep the retry pause instant in tests — see STARTUP_RETRY_PLAN.md.
;(appService as any).retryOptions = {
wait: () => Promise.resolve(),
random: () => 0
}
await appService.startUpData()
expect(deps.sasService.request).toHaveBeenCalledTimes(2)
expect(deps.eventService.showInfoModal).not.toHaveBeenCalled()
expect(deps.eventService.startupDataLoaded).toHaveBeenCalled()
})
it('shows the error modal once (not twice) when both attempts reject', async () => {
const deps = buildDeps()
deps.sasService.request.and.returnValues(
Promise.reject('timeout-1'),
Promise.reject('timeout-2')
)
const appService = buildAppService(deps)
;(appService as any).retryOptions = {
wait: () => Promise.resolve(),
random: () => 0
}
await appService.startUpData()
expect(deps.sasService.request).toHaveBeenCalledTimes(2)
expect(deps.eventService.showInfoModal).toHaveBeenCalledTimes(1)
expect(deps.licenceService.isAppActivated.value).toBeFalse()
})
it('does not retry a response that arrives but is missing required properties', async () => {
const deps = buildDeps()
deps.sasService.request.and.resolveTo({
adapterResponse: {
SYSSITE: 'SITE1',
// globvars deliberately omitted
sasdatasets: [],
saslibs: {},
xlmaps: []
}
})
const appService = buildAppService(deps)
;(appService as any).retryOptions = {
wait: () => Promise.resolve(),
random: () => 0
}
await appService.startUpData()
expect(deps.sasService.request).toHaveBeenCalledTimes(1)
expect(deps.eventService.showInfoModal).toHaveBeenCalledTimes(1)
})
})
+2 -11
View File
@@ -11,15 +11,11 @@ import { AppSettingsService } from './app-settings.service'
import { AppThemes } from '../models/AppSettings'
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
import { AppStoreService } from './app-store.service'
import { retryOnce, RetryOnceOptions } from '../shared/utils/retry-once'
@Injectable()
export class AppService {
public syssite = new BehaviorSubject<string[] | null>(null)
private environmentInfo: EnvironmentInfo = {}
// Overridable in tests to keep the retry pause instant — see
// retry-once.spec.ts and app.service.spec.ts.
private retryOptions: RetryOnceOptions = {}
constructor(
private licenceService: LicenceService,
@@ -86,13 +82,8 @@ export class AppService {
public async startUpData() {
let startupServiceError = false
// Retry once on failure — multiple concurrent DC instances (e.g. VA
// multi-page reports) can cause this request to time out even though a
// second attempt succeeds. See STARTUP_RETRY_PLAN.md.
await retryOnce(
() => this.sasService.request('public/startupservice', null),
this.retryOptions
)
await this.sasService
.request('public/startupservice', null)
.then(async (res: RequestWrapperResponse) => {
this.syssite.next([res.adapterResponse.SYSSITE])
@@ -1,274 +0,0 @@
import { VaFilterService } from './va-filter.service'
import { VaMessage } from './va-messaging.service'
/**
* Minimal stand-in for HelperService.convertJsDateToSasDate using the real
* epoch math, so date/datetime assertions use the actual SAS internal values
* without pulling in HelperService's dependency graph (SasService etc.).
*/
class HelperServiceStub {
convertJsDateToSasDate(jsDate: Date, unit = 'days'): number {
const ms = Date.UTC(
jsDate.getFullYear(),
jsDate.getMonth(),
jsDate.getDate(),
jsDate.getHours(),
jsDate.getMinutes(),
jsDate.getSeconds()
)
const sasMs = ms + 315619200000
return unit === 'days'
? Math.floor(Math.abs(sasMs / 86400000))
: sasMs / 1000
}
}
function msg(parameters: any[]): VaMessage {
return {
resultName: 'r',
rowCount: 0,
data: [],
columns: [],
parameters
} as VaMessage
}
describe('VaFilterService', () => {
let service: VaFilterService
// DC column metadata used by the metadata path. cellValidation[0] is the
// delete column and must be skipped by buildLabelToVarMap.
const cellValidation = [
{ data: 'DEL' },
{ data: 'SOME_CHAR' },
{ data: 'SOME_NUM' },
{ data: 'SOME_TIME' },
{ data: 'SOME_DATE' },
{ data: 'SOME_DATETIME' }
]
const columnHeader = [
'Delete?',
'SOME_CHAR',
'SOME_NUM',
'SOME_TIME',
'SOME_DATE',
'SOME_DATETIME'
]
const cols = [
{ NAME: 'SOME_CHAR', DDTYPE: 'CHARACTER' },
{ NAME: 'SOME_NUM', DDTYPE: 'NUMERIC' },
{ NAME: 'SOME_TIME', DDTYPE: 'TIME' },
{ NAME: 'SOME_DATE', DDTYPE: 'DATE' },
{ NAME: 'SOME_DATETIME', DDTYPE: 'DATETIME' }
]
beforeEach(() => {
service = new VaFilterService(new HelperServiceStub() as any)
})
const labelToVar = () =>
service.buildLabelToVarMap(cellValidation, columnHeader)
describe('buildLabelToVarMap', () => {
it('maps header and var name (case-insensitive), skipping the delete column', () => {
const map = labelToVar()
expect(map.get('some_char')).toBe('SOME_CHAR')
expect(map.get('some_num')).toBe('SOME_NUM')
expect(map.get('del')).toBeUndefined()
expect(map.get('delete?')).toBeUndefined()
})
})
describe('buildClauses (metadata path)', () => {
const build = (parameters: any[]) =>
service.buildClauses(msg(parameters), labelToVar(), cols)
it('builds a char equality clause, single-quote escaped', () => {
expect(build([{ label: 'SOME_CHAR', value: "O'Brien" }])).toEqual([
{
GROUP_LOGIC: 'AND',
SUBGROUP_LOGIC: 'AND',
SUBGROUP_ID: 0,
VARIABLE_NM: 'SOME_CHAR',
OPERATOR_NM: '=',
RAW_VALUE: "'O''Brien'"
}
])
})
it('builds an IN clause for multiple values', () => {
const [clause] = build([{ label: 'SOME_CHAR', value: ['A', 'B'] }])
expect(clause.OPERATOR_NM).toBe('IN')
expect(clause.RAW_VALUE).toBe("('A','B')")
})
it('passes numeric values raw and drops non-numeric (injection guard)', () => {
expect(build([{ label: 'SOME_NUM', value: '10' }])[0].RAW_VALUE).toBe(
'10'
)
expect(build([{ label: 'SOME_NUM', value: 'abc' }])).toEqual([])
})
it('converts temporal values to SAS internal numbers', () => {
expect(
build([{ label: 'SOME_TIME', value: '0:00:02' }])[0].RAW_VALUE
).toBe('2')
expect(
build([{ label: 'SOME_DATE', value: '01JUL1960' }])[0].RAW_VALUE
).toBe('182')
expect(
build([{ label: 'SOME_DATETIME', value: '01JAN1960:00:06:05' }])[0]
.RAW_VALUE
).toBe('365')
})
it('passes an already-internal (bare number) temporal value through raw', () => {
// VA sends the raw SAS internal value when the data item has no format.
expect(build([{ label: 'SOME_DATE', value: '182' }])[0].RAW_VALUE).toBe(
'182'
)
expect(
build([{ label: 'SOME_DATETIME', value: '365' }])[0].RAW_VALUE
).toBe('365')
expect(build([{ label: 'SOME_TIME', value: '2' }])[0].RAW_VALUE).toBe('2')
})
it('parses ISO date shapes identically to DATE9 (VA sends no format)', () => {
const date9 = build([{ label: 'SOME_DATE', value: '01AUG1962' }])[0]
.RAW_VALUE
// The production symptom: YYMMDDS "2025/03/31" style must parse, not drop.
expect(
build([{ label: 'SOME_DATE', value: '1962-08-01' }])[0].RAW_VALUE
).toBe(date9)
expect(
build([{ label: 'SOME_DATE', value: '1962/08/01' }])[0].RAW_VALUE
).toBe(date9)
// A real production-format value must yield a clause (not be dropped).
expect(build([{ label: 'SOME_DATE', value: '2025/03/31' }])).toHaveSize(1)
})
it('resolves ambiguous slash dates day-first (never US), disambiguating by value', () => {
const iso = build([{ label: 'SOME_DATE', value: '2025-03-31' }])[0]
.RAW_VALUE
// 31 can only be a day -> both orders resolve to 2025-03-31.
expect(
build([{ label: 'SOME_DATE', value: '31/03/2025' }])[0].RAW_VALUE
).toBe(iso) // day-first
expect(
build([{ label: 'SOME_DATE', value: '03/31/2025' }])[0].RAW_VALUE
).toBe(iso) // value forces month-first
// Genuinely ambiguous (both <= 12): default DAY-first => 4th March, NOT 3rd April.
expect(
build([{ label: 'SOME_DATE', value: '04/03/2025' }])[0].RAW_VALUE
).toBe(build([{ label: 'SOME_DATE', value: '2025-03-04' }])[0].RAW_VALUE)
// Impossible date is dropped, not rolled over.
expect(build([{ label: 'SOME_DATE', value: '31/02/2025' }])).toEqual([])
})
it('uses the column FMTNAME to resolve ambiguous dates (MMDDYY reads US month-first)', () => {
const iso = (v: string) =>
service.buildClauses(
msg([{ label: 'D', value: v }]),
new Map([['d', 'D']]),
[{ NAME: 'D', DDTYPE: 'DATE' }]
)[0]?.RAW_VALUE
const withFmt = (fmt: string, v: string) =>
service.buildClauses(
msg([{ label: 'D', value: v }]),
new Map([['d', 'D']]),
[{ NAME: 'D', DDTYPE: 'DATE', FMTNAME: fmt }]
)[0]?.RAW_VALUE
// 04/03/2025, both <= 12 (ambiguous): FMTNAME decides.
expect(withFmt('MMDDYY', '04/03/2025')).toBe(iso('2025-04-03')) // 3 April
expect(withFmt('DDMMYY', '04/03/2025')).toBe(iso('2025-03-04')) // 4 March
// Value still overrides a wrong FMTNAME: 31 can only be the day.
expect(withFmt('MMDDYY', '31/03/2025')).toBe(iso('2025-03-31'))
// Falls back to the full FORMAT (e.g. MMDDYY10.) when FMTNAME is blank.
const withFormat = service.buildClauses(
msg([{ label: 'D', value: '04/03/2025' }]),
new Map([['d', 'D']]),
[{ NAME: 'D', DDTYPE: 'DATE', FMTNAME: '', FORMAT: 'MMDDYY10.' }]
)[0]?.RAW_VALUE
expect(withFormat).toBe(iso('2025-04-03')) // 3 April, from FORMAT
})
it('parses ISO datetime shapes identically to SAS DATETIME', () => {
const sas = build([
{ label: 'SOME_DATETIME', value: '01JAN1960:00:06:05' }
])[0].RAW_VALUE
expect(
build([{ label: 'SOME_DATETIME', value: '1960-01-01T00:06:05' }])[0]
.RAW_VALUE
).toBe(sas)
expect(
build([{ label: 'SOME_DATETIME', value: '1960-01-01 00:06:05' }])[0]
.RAW_VALUE
).toBe(sas)
})
it('matches the column by label case-insensitively', () => {
expect(build([{ label: 'some_char', value: 'x' }])[0].VARIABLE_NM).toBe(
'SOME_CHAR'
)
})
it('skips params that are empty or do not map to a column', () => {
expect(build([{ label: 'SOME_CHAR', value: '' }])).toEqual([])
expect(build([{ label: 'NOT_A_COLUMN', value: 'x' }])).toEqual([])
})
})
describe('buildInitialClauses (no metadata, infer kind from value)', () => {
it('uses the VA label as the column and infers kinds by value shape', () => {
const clauses = service.buildInitialClauses(
msg([
{ name: 'pr1', label: 'SOME_CHAR', dataType: 'string', value: 'x' },
{ name: 'pr2', label: 'SOME_NUM', dataType: 'number', value: '10' },
{ name: 'pr3', label: 'SOME_TIME', value: '0:00:02' },
{ name: 'pr4', label: 'SOME_DATE', value: '01JUL1960' },
{ name: 'pr5', label: 'SOME_DATETIME', value: '01JAN1960:00:06:05' }
])
)
expect(clauses.map((c) => [c.VARIABLE_NM, c.RAW_VALUE])).toEqual([
['SOME_CHAR', "'x'"],
['SOME_NUM', '10'],
['SOME_TIME', '2'],
['SOME_DATE', '182'],
['SOME_DATETIME', '365']
])
})
it('infers ISO date/datetime shapes by value shape', () => {
const clauses = service.buildInitialClauses(
msg([
{ name: 'pr1', label: 'SOME_DATE', value: '1960-07-01' },
{ name: 'pr2', label: 'SOME_DATE2', value: '1960/07/01' },
{ name: 'pr3', label: 'SOME_DTTM', value: '1960-01-01T00:06:05' }
])
)
expect(clauses.map((c) => [c.VARIABLE_NM, c.RAW_VALUE])).toEqual([
['SOME_DATE', '182'],
['SOME_DATE2', '182'],
['SOME_DTTM', '365']
])
})
})
describe('signature', () => {
it('is case-insensitive on the column name (no reconcile reload)', () => {
const a = [
{ VARIABLE_NM: 'some_char', OPERATOR_NM: '=', RAW_VALUE: "'x'" }
] as any
const b = [
{ VARIABLE_NM: 'SOME_CHAR', OPERATOR_NM: '=', RAW_VALUE: "'x'" }
] as any
expect(service.signature(a)).toBe(service.signature(b))
})
it('changes when a value changes', () => {
const a = [{ VARIABLE_NM: 'SOME_CHAR', RAW_VALUE: "'x'" }] as any
const b = [{ VARIABLE_NM: 'SOME_CHAR', RAW_VALUE: "'y'" }] as any
expect(service.signature(a)).not.toBe(service.signature(b))
})
})
})
@@ -1,421 +0,0 @@
import { Injectable } from '@angular/core'
import { HelperService } from './helper.service'
import { VaMessage } from './va-messaging.service'
import { QueryClause } from '../models/TableData'
/** SAS data-type kind used to format a VA filter value into a DC RAW_VALUE. */
type VaColumnKind = 'char' | 'numeric' | 'time' | 'date' | 'datetime'
/**
* Translates SAS Visual Analytics data-driven content `parameters` into DC
* filter clauses. Independent of editor/HOT state the editor passes the
* loaded column metadata in so it is unit-testable on its own. Temporal
* values are converted to SAS internal numbers (matching DC's native filters)
* via the shared HelperService.
*/
@Injectable({ providedIn: 'root' })
export class VaFilterService {
private static readonly SAS_MONTHS = [
'JAN',
'FEB',
'MAR',
'APR',
'MAY',
'JUN',
'JUL',
'AUG',
'SEP',
'OCT',
'NOV',
'DEC'
]
constructor(private helperService: HelperService) {}
/** label/name (lower-cased) -> DC variable (prop) name, skipping the delete col. */
buildLabelToVarMap(
cellValidation: any[],
columnHeader: string[]
): Map<string, string> {
const map = new Map<string, string>()
for (let i = 1; i < cellValidation.length; i++) {
const varName = cellValidation[i]?.data
if (!varName) continue
map.set(
(columnHeader[i] ?? varName).toString().trim().toLowerCase(),
varName
)
map.set(varName.toString().trim().toLowerCase(), varName)
}
return map
}
/**
* Translates VA `parameters` into DC filter clauses. Each parameter is matched
* to a DC column by label; a single value becomes `col = value`, multiple
* values become `col IN (...)`. Values are formatted for the column's SAS kind.
*/
buildClauses(
msg: VaMessage,
labelToVar: Map<string, string>,
cols: any[]
): QueryClause[] {
const clauses: QueryClause[] = []
for (const param of msg.parameters || []) {
if (!param || param.value === undefined || param.value === null) continue
const key = (param.label ?? param.name ?? '')
.toString()
.trim()
.toLowerCase()
// A VA param whose label isn't a DC column (beyond case, which is handled)
// simply doesn't map — skip it.
const varName = labelToVar.get(key)
if (!varName) continue
const col = this.findCol(varName, cols)
const kind = this.columnKind(col)
const order = this.dateOrderFromFmt(col?.FMTNAME || col?.FORMAT)
const rawValues = Array.isArray(param.value) ? param.value : [param.value]
// Format/validate every value for the column's SAS type. Numeric columns
// must receive a finite number (untrusted postMessage input is
// interpolated into a SAS WHERE clause, so a non-numeric value here would
// be an injection vector); temporal -> SAS internal number; char ->
// escaped. Invalid values are dropped.
const formatted = rawValues
.filter((v) => v !== undefined && v !== null && v !== '')
.map((v) => this.formatValueByKind(v, kind, order))
.filter((v): v is string => v !== null)
if (formatted.length === 0) continue
clauses.push(this.clause(varName, formatted))
}
return clauses
}
/**
* Pre-getdata variant used on the deferred initial load, when DC column
* metadata isn't available. Uses the VA `label` as the column name and infers
* the SAS kind from the value shape, so the clause matches what the metadata
* build later produces (no reconcile reload).
*/
buildInitialClauses(msg: VaMessage): QueryClause[] {
const clauses: QueryClause[] = []
for (const param of msg.parameters || []) {
if (!param || param.value === undefined || param.value === null) continue
// The DC column name is the VA `label`; `name` is VA's internal id (pr###).
const varName = (param.label ?? param.name ?? '').toString().trim()
if (!varName) continue
const rawValues = Array.isArray(param.value) ? param.value : [param.value]
const nonEmpty = rawValues.filter(
(v) => v !== undefined && v !== null && v !== ''
)
if (nonEmpty.length === 0) continue
const formatted = nonEmpty
.map((v) =>
this.formatValueByKind(
v,
this.inferKindFromValue(String(v).trim(), param.dataType)
)
)
.filter((v): v is string => v !== null)
if (formatted.length === 0) continue
clauses.push(this.clause(varName, formatted))
}
return clauses
}
/**
* Stable signature of filter clauses for cross-reload dedup. VARIABLE_NM is
* upper-cased because the metadata-less deferred build uses VA's label case
* while the metadata build uses DC's stored column case SAS WHERE clauses
* are case-insensitive, so those are the same filter and must not trigger a
* reconcile reload. A genuine value difference still changes the signature.
*/
signature(clauses: QueryClause[]): string {
return JSON.stringify(
clauses.map((c) => ({
...c,
VARIABLE_NM: (c.VARIABLE_NM ?? '').toString().toUpperCase()
}))
)
}
private clause(varName: string, formatted: string[]): QueryClause {
return {
GROUP_LOGIC: 'AND',
SUBGROUP_LOGIC: 'AND',
SUBGROUP_ID: 0,
VARIABLE_NM: varName,
OPERATOR_NM: formatted.length === 1 ? '=' : 'IN',
RAW_VALUE:
formatted.length === 1 ? formatted[0] : `(${formatted.join(',')})`
}
}
/** The loaded column spec whose NAME matches varName (case-insensitive). */
private findCol(varName: string, cols: any[]): any {
return (cols || []).find(
(x: any) =>
(x?.NAME ?? '').toString().toUpperCase() === varName.toUpperCase()
)
}
/**
* SAS data-type kind of a column spec. DDTYPE carries
* TIME/DATE/DATETIME/NUMERIC/CHARACTER; checked DATETIME-before-DATE (substring).
*/
private columnKind(col: any): VaColumnKind {
const ddtype = (col?.DDTYPE ?? '').toString().toUpperCase()
if (ddtype.includes('DATETIME')) return 'datetime'
if (ddtype.includes('DATE')) return 'date'
if (ddtype.includes('TIME')) return 'time'
if (ddtype.includes('NUMERIC') || (col?.TYPE ?? '') === 'num') {
return 'numeric'
}
return 'char'
}
/**
* Day/month/year order declared by a column's SAS format, used to resolve
* ambiguous numeric dates. Accepts either the base FMTNAME (e.g. `DDMMYY`) or
* the full FORMAT (e.g. `DDMMYY10.`) the leading name is the same and is all
* that matters for order (width is irrelevant). This is the SOURCE column's
* format; the VA report can in principle display a different one, so
* value-based disambiguation (a group > 12 is the day) still takes precedence
* this only decides the genuinely-ambiguous case. `undefined` when the format
* is absent, month-name (DATE*), or locale-dependent (NLDATE).
*/
private dateOrderFromFmt(fmtname: any): 'YMD' | 'DMY' | 'MDY' | undefined {
const f = (fmtname ?? '').toString().toUpperCase()
if (/^MMDDYY/.test(f)) return 'MDY'
if (/^(DDMMYY|EURDF)/.test(f)) return 'DMY'
if (/^(YYMMDD|E8601DA|B8601DA)/.test(f)) return 'YMD'
return undefined
}
/**
* Infers a column's SAS kind from a VA value string (deferred load, no
* metadata). VA's temporal strings are distinctive DATE9 `ddMMMyyyy` and ISO
* `yyyy-MM-dd`/`yyyy/MM/dd`, optionally with a `[ T:]HH:MM[:SS]` time part, and
* `H:MM:SS` so they're detected by shape; otherwise fall back to the VA
* `dataType`.
*/
private inferKindFromValue(value: string, dataType?: string): VaColumnKind {
const monName = String.raw`\d{1,2}[A-Za-z]{3}\d{2,4}`
const ymd = String.raw`\d{4}[-/.]\d{1,2}[-/.]\d{1,2}` // 4-digit year first
const dmy = String.raw`\d{1,2}[-/.]\d{1,2}[-/.]\d{4}` // 4-digit year last
const date = `${monName}|${ymd}|${dmy}`
if (new RegExp(`^(?:${date})[ T:]\\d{1,2}:\\d{2}(:\\d{2})?$`).test(value)) {
return 'datetime'
}
if (new RegExp(`^(?:${date})$`).test(value)) return 'date'
if (/^\d{1,2}:\d{2}(:\d{2})?(\.\d+)?$/.test(value)) return 'time'
if (dataType === 'number') return 'numeric'
return 'char'
}
/**
* Formats one VA filter value into a DC RAW_VALUE for the column kind, or null
* when invalid (dropped). Matches DC's native filters, which send the NUMERIC
* INTERNAL value (not SAS literals those error out in the backend):
* - numeric -> finite number, raw (else null injection guard)
* - time -> seconds since midnight (VA: "0:00:02" -> 2)
* - date -> days since 1960-01-01 (VA: "01JUL1960" -> 182)
* - datetime -> seconds since 1960-01-01 (VA: "01JAN1960:00:06:05" -> 365)
* - char -> single-quote escaped
* Temporal strings are regex-parsed (digits/letters/colons only), so a value
* either yields a finite number or is dropped also the injection guard.
* Date/datetime reuse HelperService.convertJsDateToSasDate so the value is
* byte-identical to what DC's own pickers produce.
*
* A temporal value that is already a bare number is the SAS internal value
* itself (VA sends it unformatted when the data item carries no format / is
* used as a measure), so it is passed through raw. This is unambiguous because
* the only date formats in use (DATE9, ISO) always contain a letter or a
* separator a plain integer is never a formatted date.
*/
private formatValueByKind(
value: any,
kind: VaColumnKind,
order?: 'YMD' | 'DMY' | 'MDY'
): string | null {
const s = String(value).trim()
if (s === '') return null
switch (kind) {
case 'numeric': {
const num = Number(s)
return Number.isFinite(num) ? `${num}` : null
}
case 'time': {
if (this.isBareNumber(s)) return `${Number(s)}`
const secs = this.timeToSeconds(s)
return secs === null ? null : `${secs}`
}
case 'date': {
if (this.isBareNumber(s)) return `${Number(s)}`
const d = this.parseDate(s, order)
return d
? `${this.helperService.convertJsDateToSasDate(d, 'days')}`
: null
}
case 'datetime': {
if (this.isBareNumber(s)) return `${Number(s)}`
const d = this.parseDatetime(s, order)
return d
? `${this.helperService.convertJsDateToSasDate(d, 'seconds')}`
: null
}
default:
return `'${s.replace(/'/g, "''")}'`
}
}
/** "H:MM[:SS][.f]" -> seconds since midnight, or null if not a valid time. */
private timeToSeconds(value: string): number | null {
if (!/^\d{1,2}:\d{2}(:\d{2})?(\.\d+)?$/.test(value)) return null
const p = value.split('.')[0].split(':')
const h = Number(p[0])
const m = Number(p[1])
const sec = p.length === 3 ? Number(p[2]) : 0
if (![h, m, sec].every(Number.isFinite)) return null
return h * 3600 + m * 60 + sec
}
/**
* VA date string -> local JS Date, or null. VA sends no format metadata, so the
* shape is sniffed. Accepted forms (separator may be `-`, `/`, or `.`):
* - DATE9 "ddMMM[yy]yy" (e.g. 01AUG1962)
* - ISO/YMD "yyyy-MM-dd" (4-digit year first, e.g. 2025-03-31)
* - year-last "dd/mm/yyyy" or "mm/dd/yyyy" (4-digit year last)
*
* Day/month order for the year-last form is resolved in priority order:
* 1. by value a group > 12 must be the day (ground truth);
* 2. by `order` the column's declared FMTNAME order (DMY/MDY/YMD);
* 3. default DAY-FIRST never US month-first matching SAS's DDMMYY
* default and non-US convention.
* `new Date(y, m-1, d)` is local, matching HelperService.convertJsDateToSasDate
* so the value is byte-identical to DC's own pickers. The constructed date is
* round-trip validated to reject impossible values (e.g. 31/02) rather than
* silently roll over.
*/
private parseDate(value: string, order?: 'YMD' | 'DMY' | 'MDY'): Date | null {
return this.parseMonthNameDate(value) ?? this.parseNumericDate(value, order)
}
/** DATE9 "ddMMM[yy]yy" (e.g. 01AUG1962, 01AUG62) -> local Date, or null. */
private parseMonthNameDate(value: string): Date | null {
const m = /^(\d{1,2})([A-Za-z]{3})(\d{2}|\d{4})$/.exec(value)
if (!m) return null
const [, day, monthAbbr, year] = m
const monthIndex = VaFilterService.SAS_MONTHS.indexOf(
monthAbbr.toUpperCase()
)
if (monthIndex < 0) return null
return this.buildDate(this.fullYear(year), monthIndex + 1, Number(day))
}
/**
* Numeric date with `-`, `/`, or `.` separators -> local Date, or null:
* - year-first "yyyy-MM-dd" -> YMD directly;
* - year-last "dd/mm/yyyy" or "mm/dd/yyyy" -> day/month resolved by
* resolveDayMonth (value, then column order, then day-first default).
* A 4-digit year (leading or trailing) is required to anchor the parse;
* an all-2-digit value (e.g. "03/04/05") is too ambiguous and is dropped.
*/
private parseNumericDate(
value: string,
order?: 'YMD' | 'DMY' | 'MDY'
): Date | null {
const m = /^(\d{1,4})[-/.](\d{1,2})[-/.](\d{1,4})$/.exec(value)
if (!m) return null
const [, first, middle, last] = m
const yearIsFirst = first.length === 4
const yearIsLast = last.length === 4
if (yearIsFirst) {
return this.buildDate(Number(first), Number(middle), Number(last))
}
if (!yearIsLast) return null
const { day, month } = this.resolveDayMonth(
Number(first),
Number(middle),
order
)
return this.buildDate(Number(last), month, day)
}
/**
* Resolves the day/month of a year-last date whose two leading groups (`a`,
* `b`) are in unknown order, in priority:
* 1. by value a group > 12 must be the day (ground truth);
* 2. by `order` only MDY flips to month-first; DMY/YMD keep day-first;
* 3. default DAY-first (`a` is the day), never US month-first.
*/
private resolveDayMonth(
a: number,
b: number,
order?: 'YMD' | 'DMY' | 'MDY'
): { day: number; month: number } {
if (a > 12 && b <= 12) return { day: a, month: b }
if (b > 12 && a <= 12) return { day: b, month: a }
if (order === 'MDY') return { day: b, month: a }
return { day: a, month: b }
}
/** Local Date from 1-based month, round-trip validated (null if impossible). */
private buildDate(
year: number,
month: number,
day: number,
hours = 0,
minutes = 0,
seconds = 0
): Date | null {
if (month < 1 || month > 12 || day < 1 || day > 31) return null
const d = new Date(year, month - 1, day, hours, minutes, seconds, 0)
// Reject rollovers (e.g. 31 Feb -> 3 Mar) so a bad value is dropped, not
// silently converted to the wrong SAS date.
if (
d.getFullYear() !== year ||
d.getMonth() !== month - 1 ||
d.getDate() !== day
) {
return null
}
return d
}
/**
* VA datetime string -> local JS Date, or null. The date part is any shape
* accepted by parseDate; the time part is "HH:MM[:SS]". They are joined by the
* SAS ":" separator (e.g. 01JAN1960:05:13:03), an ISO "T", or a space
* (e.g. 2025-03-31T05:13:03, 2025-03-31 05:13:03).
*/
private parseDatetime(
value: string,
order?: 'YMD' | 'DMY' | 'MDY'
): Date | null {
const m = /^(.+?)[ T:](\d{1,2}:\d{2}(?::\d{2})?)$/.exec(value)
if (!m) return null
const date = this.parseDate(m[1], order)
if (!date) return null
const t = m[2].split(':')
date.setHours(Number(t[0]), Number(t[1]), Number(t[2] || 0), 0)
return date
}
/** True when the value is a bare (already-internal) SAS number: optional sign,
* digits, optional fraction. No letters/separators so never a formatted date. */
private isBareNumber(value: string): boolean {
return /^-?\d+(\.\d+)?$/.test(value) && Number.isFinite(Number(value))
}
/** 2-digit SAS year -> 4-digit (SAS default cutoff: 00-19 -> 20xx, else 19xx). */
private fullYear(year: string): number {
if (year.length === 4) return Number(year)
const y = Number(year)
return y <= 19 ? 2000 + y : 1900 + y
}
}
@@ -1,102 +0,0 @@
import { VaMessagingService, VaMessage } from './va-messaging.service'
// Sample message taken from the official SAS DDC docs (brush column included).
const SAMPLE = {
version: '1',
resultName: 'dd40',
rowCount: 3,
availableRowCount: 3,
data: [
['Finch', 95000.0, 0.4285],
['Jones', 26000.0, 0.0],
['Smith', 108000.0, 0.5]
],
columns: [
{ name: 'bi184', label: 'dealer', type: 'string' },
{
name: 'bi258',
label: 'sales',
type: 'number',
usage: 'quantitative',
aggregation: 'sum'
},
{ name: 'ri1', type: 'number', usage: 'brush' }
]
}
describe('VaMessagingService', () => {
let service: VaMessagingService
beforeEach(() => {
service = new VaMessagingService()
})
describe('parseMessage', () => {
it('parses a valid DDC message and defaults parameters to []', () => {
const msg = service.parseMessage({ data: SAMPLE } as MessageEvent)
expect(msg).not.toBeNull()
expect(msg!.resultName).toBe('dd40')
expect(msg!.rowCount).toBe(3)
expect(msg!.parameters).toEqual([])
})
it('returns null for unrelated / malformed messages', () => {
expect(service.parseMessage({ data: null } as MessageEvent)).toBeNull()
expect(
service.parseMessage({ data: 'hello' } as unknown as MessageEvent)
).toBeNull()
expect(
service.parseMessage({ data: { columns: [] } } as MessageEvent)
).toBeNull()
})
})
describe('dataColumns', () => {
it('excludes brush columns and keeps original indices', () => {
const msg = service.parseMessage({ data: SAMPLE } as MessageEvent)!
const cols = service.dataColumns(msg)
expect(cols.map((c) => c.column.name)).toEqual(['bi184', 'bi258'])
expect(cols.map((c) => c.index)).toEqual([0, 1])
})
})
describe('onData', () => {
const sameOrigin = window.location.origin
it('invokes the callback for valid messages and unsubscribes cleanly', () => {
const received: VaMessage[] = []
const off = service.onData((m) => received.push(m))
window.dispatchEvent(
new MessageEvent('message', { data: SAMPLE, origin: sameOrigin })
)
expect(received.length).toBe(1)
expect(received[0].resultName).toBe('dd40')
// unrelated traffic is ignored
window.dispatchEvent(
new MessageEvent('message', { data: { foo: 1 }, origin: sameOrigin })
)
expect(received.length).toBe(1)
off()
window.dispatchEvent(
new MessageEvent('message', { data: SAMPLE, origin: sameOrigin })
)
expect(received.length).toBe(1)
})
it('rejects messages from an untrusted origin', () => {
const received: VaMessage[] = []
service.onData((m) => received.push(m))
window.dispatchEvent(
new MessageEvent('message', {
data: SAMPLE,
origin: 'https://evil.example.com'
})
)
expect(received.length).toBe(0)
})
})
})
@@ -1,218 +0,0 @@
import { Injectable } from '@angular/core'
/**
* SAS Visual Analytics data-driven content (DDC) postMessage interface.
*
* Contract (from the official SAS docs and the sample repo
* https://github.com/sassoftware/sas-visualanalytics-thirdpartyvisualizations):
* VA shares data with the data-driven content object via window.postMessage().
* The content registers a `message` listener; `event.data` is a JSON object.
* To act as the source of an action (selection / brushing) or to show an
* instructional message, the content posts a message back to `window.parent`,
* echoing the `resultName`.
*/
export interface VaColumn {
/** Internal VA column id, e.g. "bi184". */
name: string
/** Human label, e.g. "dealer". May be absent (e.g. on brush columns). */
label?: string
type: string
/** "categorical" | "quantitative" | "brush" | ... */
usage?: string
aggregation?: string
format?: any
}
export interface VaParameter {
/** VA internal id, e.g. "pr273". NOT the DC column. */
name: string
/** Human label — equals the DC column name in our setup, e.g. "some_dropdown". */
label?: string
/** "number" | "string" — VA's value type (used to decide numeric vs char). */
dataType?: string
type?: string
format?: any
value: any
}
export interface VaMessage {
version?: string
/** Required to echo back on any message to VA. */
resultName: string
rowCount: number
availableRowCount?: number
/** Row-major 2-D array; cell order matches `columns`. Measures are unformatted. */
data: any[][]
columns: VaColumn[]
/** Only the parameters used by the query are returned. */
parameters: VaParameter[]
}
@Injectable({ providedIn: 'root' })
export class VaMessagingService {
/** Result name from the most recent message, needed for the back-channel. */
private resultName: string | null = null
/**
* Origin of the last trusted inbound message used as the explicit
* targetOrigin for the back-channel so payloads are never broadcast to an
* unintended parent.
*/
private parentOrigin: string | null = null
/**
* Validates that a message comes from a trusted source: our hosting frame
* (the normal SAS VA data-driven content case) or the same origin (local dev /
* tests). Untrusted messages e.g. from an unrelated window or a malicious
* iframe are rejected.
*/
private isTrustedSource(event: MessageEvent): boolean {
if (window.parent !== window && event.source === window.parent) return true
return !!event.origin && event.origin === window.location.origin
}
/**
* Cross-reload state. Applying a VA filter navigates to
* /editor/<table>/<FILTER_RK>, which recreates the editor component, so the
* desired filter + column visibility are held on this root singleton and
* re-applied after the reload.
*/
filterSignature: string | null = null
/** DC variable names that should be visible (the rest are hidden). */
visibleColumns: string[] | null = null
/** True once the pre-bootstrap early buffer has been drained (drain-once). */
private earlyDrained = false
/**
* Registers a window `message` listener and invokes `callback` for every
* valid DDC message. The latest message itself is read via `latestMessage()`
* from the always-on `window.__vaLastMessage` buffer, not stored here.
* Returns an unsubscribe function that removes the listener.
*/
onData(callback: (msg: VaMessage) => void): () => void {
const handler = (event: MessageEvent) => {
if (!this.isTrustedSource(event)) return
const parsed = this.parseMessage(event)
if (parsed) {
this.resultName = parsed.resultName
if (event.origin) this.parentOrigin = event.origin
callback(parsed)
}
}
window.addEventListener('message', handler, false)
// Replay any DDC message captured by the early listener before
// the app (and this listener) existed. VA pushes its message once on iframe
// load — which happens before Angular bootstraps — so without this the very
// first message is lost entirely and the editor loads unfiltered.
this.replayEarlyMessages(callback)
return () => window.removeEventListener('message', handler, false)
}
/**
* The true latest DDC message, from the always-on `window.__vaLastMessage`
* buffer (set in va-early.js). That listener is registered at page parse and
* never removed, so it has NO gaps including the window during an editor
* reload when the app's own listener is briefly absent. So a message that
* arrives mid-reload (e.g. a rapid filter change) is still here.
*/
latestMessage(): VaMessage | null {
const captured = (window as unknown as { __vaLastMessage?: any })
.__vaLastMessage
return captured ? this.parseData(captured.data) : null
}
/**
* Replays the latest VA message captured by the early `window.__vaLastMessage`
* listener (set up in va-early.js), closing the pre-bootstrap race. Only runs
* when actually framed (DDC context).
*/
private replayEarlyMessages(callback: (msg: VaMessage) => void): void {
if (window.parent === window) return
// Drain only once per page lifetime. This service is a root singleton, so
// the flag persists across editor reloads — without it every reload would
// re-replay the captured message and fight the live filter (loop).
if (this.earlyDrained) return
this.earlyDrained = true
const captured = (window as unknown as { __vaLastMessage?: any })
.__vaLastMessage
const parsed = this.parseData(captured && captured.data)
if (!parsed) return
this.resultName = parsed.resultName
if (captured.origin) this.parentOrigin = captured.origin
callback(parsed)
}
/**
* Parses a raw window MessageEvent into a VaMessage, or null when it is not a
* recognisable DDC message (e.g. unrelated postMessage traffic).
*/
parseMessage(event: MessageEvent): VaMessage | null {
return this.parseData(event && event.data)
}
/**
* Parses a raw message payload (from a MessageEvent or the early buffer) into
* a VaMessage, or null when it is not a recognisable DDC message.
*/
private parseData(data: any): VaMessage | null {
if (!data || typeof data !== 'object') return null
if (typeof data.resultName !== 'string') return null
if (!Array.isArray(data.columns) || !Array.isArray(data.data)) return null
return {
version: data.version,
resultName: data.resultName,
rowCount: data.rowCount,
availableRowCount: data.availableRowCount,
data: data.data,
columns: data.columns,
parameters: Array.isArray(data.parameters) ? data.parameters : []
}
}
/**
* Columns that carry grid data excludes `usage: "brush"` columns, which
* encode linked-selection state rather than data.
*/
dataColumns(msg: VaMessage): { column: VaColumn; index: number }[] {
return msg.columns
.map((column, index) => ({ column, index }))
.filter(({ column }) => column.usage !== 'brush')
}
/**
* Back-channel (DDC VA) selection / source-of-action. Designed per the
* SAS contract; not wired into the UI in v1.
*/
sendSelection(rows: number[]): void {
if (this.resultName === null) return
this.sendMessage({
resultName: this.resultName,
selections: rows.map((row) => ({ row }))
})
}
/**
* Back-channel (DDC VA) custom instructional message, shown in the report
* designer when the assigned data does not meet the visual's requirements.
*/
sendInstructionalMessage(message: string): void {
if (this.resultName === null) return
this.sendMessage({ resultName: this.resultName, message })
}
private sendMessage(message: any): void {
// Prefer the validated origin captured from the inbound VA message; only
// fall back to the referrer/own location when we have not yet received one.
const target =
this.parentOrigin ||
(window.location !== window.parent.location
? document.referrer
: document.location.href)
if (!target) return
window.parent.postMessage(message, target)
}
}
@@ -7,62 +7,66 @@
>
<h3 class="modal-title">
{{ data.modalTitle }}
@if (data.sasService && data.sasService.length > 0) {
<p class="sasService mt-0">
SAS Service: <strong>{{ data.sasService }}</strong>
</p>
}
<p
*ngIf="data.sasService && data.sasService.length > 0"
class="sasService mt-0"
>
SAS Service: <strong>{{ data.sasService }}</strong>
</p>
</h3>
<div class="modal-body">
<div [innerHTML]="data.message" class="abortMsg"></div>
@if (data.details !== null) {
<div class="systext">
<p>
<strong>SYSWARNINGTEXT:</strong> {{ data.details.SYSWARNINGTEXT }}
</p>
<p><strong>SYSERRORTEXT:</strong> {{ data.details.SYSERRORTEXT }}</p>
<p><strong>MAC:</strong> {{ data.details.MAC }}</p>
</div>
}
<div *ngIf="data.details !== null" class="systext">
<p><strong>SYSWARNINGTEXT:</strong> {{ data.details.SYSWARNINGTEXT }}</p>
<p><strong>SYSERRORTEXT:</strong> {{ data.details.SYSERRORTEXT }}</p>
<p><strong>MAC:</strong> {{ data.details.MAC }}</p>
</div>
</div>
<div class="modal-footer">
@if (showConfiguratorButton(data.sasService)) {
<button
type="button"
class="btn btn-primary"
(click)="openConfigurator()"
>
Open configurator
</button>
}
<button
*ngIf="showConfiguratorButton(data.sasService)"
type="button"
class="btn btn-primary"
(click)="openConfigurator()"
>
Open configurator
</button>
@if (data.details !== null) {
<button
type="button"
class="btn btn-primary"
(click)="openRequestsModal()"
>
Open requests modal
</button>
}
<button
*ngIf="data.details !== null"
type="button"
class="btn btn-primary"
(click)="openRequestsModal()"
>
Open requests modal
</button>
@if (data.details?.LOG && (data.details?.LOG?.trim())!.length > 0) {
<button type="button" class="btn btn-primary" (click)="downloadLog()">
Download log
</button>
}
<button
*ngIf="data.details?.LOG && (data.details?.LOG?.trim())!.length > 0"
type="button"
class="btn btn-primary"
(click)="downloadLog()"
>
Download log
</button>
@if (!forceReload) {
<button type="button" class="btn btn-primary" (click)="closeAbortModal()">
Close
</button>
}
<button
*ngIf="!forceReload"
type="button"
class="btn btn-primary"
(click)="closeAbortModal()"
>
Close
</button>
@if (forceReload) {
<button type="button" class="btn btn-primary" (click)="reload()">
Reload
</button>
}
<button
*ngIf="forceReload"
type="button"
class="btn btn-primary"
(click)="reload()"
>
Reload
</button>
</div>
</clr-modal>
@@ -1,18 +1,15 @@
@if (hasOpenAlert) {
<clr-alerts>
@for (alert of alerts; track alert) {
<clr-alert
[clrAlertType]="alert.type"
[clrAlertAppLevel]="true"
[(clrAlertClosed)]="alert.closed"
(clrAlertClosedChange)="onAlertClose()"
>
<div class="alert-item">
<span class="alert-text">
{{ alert.message }}
</span>
</div>
</clr-alert>
}
</clr-alerts>
}
<clr-alerts *ngIf="hasOpenAlert">
<clr-alert
*ngFor="let alert of alerts"
[clrAlertType]="alert.type"
[clrAlertAppLevel]="true"
[(clrAlertClosed)]="alert.closed"
(clrAlertClosedChange)="onAlertClose()"
>
<div class="alert-item">
<span class="alert-text">
{{ alert.message }}
</span>
</div>
</clr-alert>
</clr-alerts>
@@ -41,10 +41,12 @@
<ng-content></ng-content>
</div>
@if (options.children.length > 0 && enableLoadMore) {
<option data-type="load-more" class="load-more">
{{ loadingMore ? 'Loading...' : 'LOAD MORE' }}
</option>
}
<option
*ngIf="options.children.length > 0 && enableLoadMore"
data-type="load-more"
class="load-more"
>
{{ loadingMore ? 'Loading...' : 'LOAD MORE' }}
</option>
</div>
</div>
@@ -6,37 +6,35 @@
>
<h3 class="modal-title center text-center color-darker-gray">Dataset Meta</h3>
<div class="modal-body">
@if (dsmetaTabs.length < 1) {
<p class="text-center">No dataset meta to show.</p>
}
<p *ngIf="dsmetaTabs.length < 1" class="text-center">
No dataset meta to show.
</p>
<clr-tabs clrLayout="vertical">
@for (tab of tabs; track tab; let index = $index) {
<clr-tab>
<button clrTabLink id="link1">{{ tab.name }}</button>
<clr-tab-content
id="content1"
*clrIfActive="index === 0"
class="d-flex clr-justify-content-center w-100"
>
<clr-datagrid>
@for (col of tab.colsToDisplay; track col) {
<clr-dg-column>{{ col.colName || col.colKey }}</clr-dg-column>
}
@for (info of tab.meta; track info) {
<clr-dg-row
(click)="tab.onRowClick ? tab.onRowClick(info) : ''"
class="clickable-row"
>
@for (col of tab.colsToDisplay; track col) {
<clr-dg-cell>{{ info[col.colKey] }}</clr-dg-cell>
}
</clr-dg-row>
}
</clr-datagrid>
</clr-tab-content>
</clr-tab>
}
<clr-tab *ngFor="let tab of tabs; let index = index">
<button clrTabLink id="link1">{{ tab.name }}</button>
<clr-tab-content
id="content1"
*clrIfActive="index === 0"
class="d-flex clr-justify-content-center w-100"
>
<clr-datagrid>
<ng-container *ngFor="let col of tab.colsToDisplay">
<clr-dg-column>{{ col.colName || col.colKey }}</clr-dg-column>
</ng-container>
<clr-dg-row
(click)="tab.onRowClick ? tab.onRowClick(info) : ''"
class="clickable-row"
*ngFor="let info of tab.meta"
>
<ng-container *ngFor="let col of tab.colsToDisplay">
<clr-dg-cell>{{ info[col.colKey] }}</clr-dg-cell>
</ng-container>
</clr-dg-row>
</clr-datagrid>
</clr-tab-content>
</clr-tab>
</clr-tabs>
</div>
<div class="modal-footer">
@@ -1,180 +1,164 @@
<clr-tree>
@if (libraryList) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchLibTreeInput
placeholder="Libraries"
name="input"
[(ngModel)]="librariesSearch"
(keyup)="libraryOnFilter()"
autocomplete="off"
/>
@if (searchLibTreeInput.value.length < 1) {
<clr-icon shape="search"></clr-icon>
}
@if (searchLibTreeInput.value.length > 0) {
<clr-tree-node *ngIf="libraryList" class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchLibTreeInput
placeholder="Libraries"
name="input"
[(ngModel)]="librariesSearch"
(keyup)="libraryOnFilter()"
autocomplete="off"
/>
<clr-icon
*ngIf="searchLibTreeInput.value.length < 1"
shape="search"
></clr-icon>
<clr-icon
*ngIf="searchLibTreeInput.value.length > 0"
(click)="librariesSearch = ''; libraryOnFilter()"
shape="times"
></clr-icon>
</div>
</clr-tree-node>
<ng-container *ngFor="let library of libraryList">
<clr-tree-node
#libTreeNode
(click)="treeNodeClicked($event, library, libraryList)"
*ngIf="!library['hidden'] && library['inForeground']"
[(clrExpanded)]="library['expanded']"
[clrLoading]="library['loadingTables'] && !library.tables"
[class.clr-expanded]="library['expanded']"
>
<p
(click)="
lib = library.LIBRARYID;
libraryOnClick(lib || '', library, libTreeNode)
"
class="m-0 cursor-pointer"
>
<clr-icon shape="rack-server"></clr-icon>
{{ library.LIBRARYNAME }}
</p>
<clr-tree-node *ngIf="library['tables']" class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchTreeInput
placeholder="Tables"
name="input"
[(ngModel)]="library['searchString']"
(keyup)="treeOnFilter(library, 'tables')"
autocomplete="off"
/>
<clr-icon
(click)="librariesSearch = ''; libraryOnFilter()"
*ngIf="searchTreeInput.value.length < 1"
shape="search"
></clr-icon>
<clr-icon
*ngIf="searchTreeInput.value.length > 0"
(click)="
searchTreeInput.value = '';
library['searchString'] = '';
treeOnFilter(library, 'tables.TABLENAME')
"
shape="times"
></clr-icon>
}
</div>
</clr-tree-node>
}
</div>
</clr-tree-node>
@for (library of libraryList; track library) {
@if (!library['hidden'] && library['inForeground']) {
<clr-tree-node
#libTreeNode
(click)="treeNodeClicked($event, library, libraryList)"
[(clrExpanded)]="library['expanded']"
[clrLoading]="library['loadingTables'] && !library.tables"
[class.clr-expanded]="library['expanded']"
(click)="treeNodeClicked($event, libTable, library['tables'])"
*ngFor="let libTable of library['tables']; let index = index"
[(clrExpanded)]="libTable['expanded']"
[clrLoading]="libTable['loadingColumns'] && !libTable.columns"
[class.clr-expanded]="libTable['expanded']"
>
<p
(click)="
lib = library.LIBRARYID;
libraryOnClick(lib || '', library, libTreeNode)
<clr-tooltip
*ngVar="
index + 1 >
licenceState.value.tables_in_library_limit as tableLocked
"
class="m-0 cursor-pointer"
>
<clr-icon shape="rack-server"></clr-icon>
{{ library.LIBRARYNAME }}
</p>
@if (library['tables']) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchTreeInput
placeholder="Tables"
name="input"
[(ngModel)]="library['searchString']"
(keyup)="treeOnFilter(library, 'tables')"
autocomplete="off"
/>
@if (searchTreeInput.value.length < 1) {
<clr-icon shape="search"></clr-icon>
}
@if (searchTreeInput.value.length > 0) {
<button
clrTooltipTrigger
(click)="
!tableLocked
? tableOnClick(libTable.TABLEURI, libTable, library)
: ''
"
class="clr-treenode-link"
[class.dc-locked-control]="tableLocked"
[class.active]="libTabActive(library.LIBRARYREF, libTable)"
>
<ng-container [ngSwitch]="libTable.includes('-FC')">
<clr-icon *ngSwitchCase="true" shape="bolt"></clr-icon>
<clr-icon *ngSwitchCase="false" shape="table"></clr-icon>
</ng-container>
{{ libTable.replace('-FC', '') }}
</button>
<clr-tooltip-content
clrPosition="bottom-right"
clrSize="lg"
*clrIfOpen
>
<span *ngIf="tableLocked">
To unlock all tables, contact support&#64;datacontroller.io
</span>
</clr-tooltip-content>
<ng-container *ngIf="hasColumns">
<clr-tree-node *ngIf="libTable['columns']" class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchTreeInput
placeholder="Columns"
name="input"
[(ngModel)]="libTable['searchString']"
(keyup)="treeOnFilter(libTable, 'columns.COLNAME')"
autocomplete="off"
/>
<clr-icon
*ngIf="searchTreeInput.value.length < 1"
shape="search"
></clr-icon>
<clr-icon
*ngIf="searchTreeInput.value.length > 0"
(click)="
searchTreeInput.value = '';
library['searchString'] = '';
treeOnFilter(library, 'tables.TABLENAME')
libTable['searchString'] = '';
treeOnFilter(libTable, 'columns.COLNAME')
"
shape="times"
></clr-icon>
}
</div>
</clr-tree-node>
}
@for (
libTable of library['tables'];
track libTable;
let index = $index
) {
<clr-tree-node
(click)="treeNodeClicked($event, libTable, library['tables'])"
[(clrExpanded)]="libTable['expanded']"
[clrLoading]="libTable['loadingColumns'] && !libTable.columns"
[class.clr-expanded]="libTable['expanded']"
>
<clr-tooltip
*ngVar="
index + 1 >
licenceState.value.tables_in_library_limit as tableLocked
"
>
</div>
</clr-tree-node>
<clr-tree-node *ngFor="let libColumn of libTable['columns']">
<button
clrTooltipTrigger
(click)="
!tableLocked
? tableOnClick(libTable.TABLEURI, libTable, library)
: ''
"
(click)="columnOnClick(libColumn, library, libTable)"
class="clr-treenode-link"
[class.dc-locked-control]="tableLocked"
[class.active]="libTabActive(library.LIBRARYREF, libTable)"
[class.column-active]="libColumnActive(libColumn.COLURI)"
>
@switch (libTable.includes('-FC')) {
@case (true) {
<clr-icon shape="bolt"></clr-icon>
}
@case (false) {
<clr-icon shape="table"></clr-icon>
}
}
{{ libTable.replace('-FC', '') }}
<clr-icon shape="objects"></clr-icon>
{{ libColumn.COLNAME }}
</button>
<clr-tooltip-content
clrPosition="bottom-right"
clrSize="lg"
*clrIfOpen
>
@if (tableLocked) {
<span>
To unlock all tables, contact support&#64;datacontroller.io
</span>
}
</clr-tooltip-content>
@if (hasColumns) {
@if (libTable['columns']) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchTreeInput
placeholder="Columns"
name="input"
[(ngModel)]="libTable['searchString']"
(keyup)="treeOnFilter(libTable, 'columns.COLNAME')"
autocomplete="off"
/>
@if (searchTreeInput.value.length < 1) {
<clr-icon shape="search"></clr-icon>
}
@if (searchTreeInput.value.length > 0) {
<clr-icon
(click)="
searchTreeInput.value = '';
libTable['searchString'] = '';
treeOnFilter(libTable, 'columns.COLNAME')
"
shape="times"
></clr-icon>
}
</div>
</clr-tree-node>
}
@for (libColumn of libTable['columns']; track libColumn) {
<clr-tree-node>
<button
(click)="columnOnClick(libColumn, library, libTable)"
class="clr-treenode-link"
[class.column-active]="libColumnActive(libColumn.COLURI)"
>
<clr-icon shape="objects"></clr-icon>
{{ libColumn.COLNAME }}
</button>
</clr-tree-node>
}
}
</clr-tooltip>
</clr-tree-node>
}
</clr-tree-node>
</ng-container>
</clr-tooltip>
</clr-tree-node>
}
}
</clr-tree-node>
</ng-container>
</clr-tree>
@if (librariesPaging) {
<div class="w-100 text-center">
<span class="spinner spinner-sm"> Loading... </span>
</div>
}
<div *ngIf="librariesPaging" class="w-100 text-center">
<span class="spinner spinner-sm"> Loading... </span>
</div>
@@ -1,26 +1,23 @@
import Handsontable from 'handsontable'
import { datetimeValidator } from '../validations/hot-custom-validators'
import { DATETIME_FORMAT } from '../utils/mapIntlCellTypes'
/**
* Cell types for the date/time/datetime columns after the HOT 17 migration.
*
* All three DISPLAY the raw stored ISO string verbatim `YYYY-MM-DD`,
* `HH:mm:ss`, `YYYY-MM-DD HH:mm:ss` via an identity `valueFormatter`, so the
* cell text is stable and locale-independent. The native picker editors still
* follow the OS regional format while open; only the painted cell is ISO.
* The stored/submitted value is unchanged.
* `date` and `time` use Handsontable's BUILT-IN `intl-date` / `intl-time` cell
* types as-is a native (browser/OS) picker editor plus the Intl renderer that
* formats the displayed value per the cell's `locale` + `dateFormat`/`timeFormat`
* (set as cell meta in mapIntlCellTypes). They are intentionally NOT re-registered
* here, so their localizing `valueFormatter` stays active. The stored/submitted
* value remains the raw ISO string; only the display is localized.
*
* `intl-date` and `intl-time` are HOT built-ins; we re-register them by spreading
* the built-in definition (keeping its native picker editor + ISO validator) and
* swapping only the `valueFormatter` for the identity one. `intl-datetime` has no
* built-in it pairs a native `datetime-local` picker with the identity
* formatter. valueFormatter is applied render-only (tableView), so copy/paste and
* the stored value are untouched.
* Only `intl-datetime` needs registering HOT has no built-in datetime type. It
* pairs a native `datetime-local` picker with a `valueFormatter` that formats the
* stored `YYYY-MM-DD HH:mm:ss` per the cell's locale (mirroring how the built-in
* intl-date/intl-time types localize, via tableView's `renderer.valueFormatter`).
*/
/** Identity formatter — display the stored ISO string as-is (no localization). */
const isoPassthrough = (value: any): any => value
const SPACE_ISO = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}(?::\d{2})?)$/
const BaseTextEditor = Handsontable.editors.getEditor('text') as any
@@ -66,34 +63,39 @@ class IntlDatetimeEditor extends BaseTextEditor {
}
/**
* Re-registers a built-in HOT cell type (`intl-date` / `intl-time`) with only its
* `valueFormatter` swapped for the ISO passthrough the native picker editor and
* ISO validator are reused from the built-in definition. Idempotent: a second
* call reads back our already-overridden type, which still carries the original
* editor/validator.
* Localizes a stored `YYYY-MM-DD HH:mm:ss` datetime per the cell's locale.
* Invoked by Handsontable via `renderer.valueFormatter` (tableView) before the
* text renderer paints the cell, so the stored value is never mutated. Falls
* back to the raw value when it is empty or unparseable.
*/
const overrideWithPassthrough = (type: string) => {
const builtin = Handsontable.cellTypes.getCellType(type) as any
Handsontable.cellTypes.registerCellType(type, {
...builtin,
valueFormatter: isoPassthrough
})
function intlDatetimeValueFormatter(value: any, cellProperties: any): any {
if (typeof value !== 'string') return value
const match = value.match(SPACE_ISO)
if (!match) return value
const date = new Date(`${match[1]}T${match[2]}`)
if (isNaN(date.getTime())) return value
const options: Intl.DateTimeFormatOptions =
cellProperties.datetimeFormat || DATETIME_FORMAT
const locale = cellProperties.locale || window.navigator.language
return new Intl.DateTimeFormat(locale, options).format(date)
}
/**
* Registers the Intl date/time/datetime cell types. Idempotent safe to call
* per DcValidator construction. All three display the stored ISO value verbatim.
* Registers the `intl-datetime` cell type. Idempotent safe to call per
* DcValidator construction. `intl-date`/`intl-time` are not registered here;
* the built-in HOT types are used directly.
*/
export const registerIntlCellTypes = () => {
overrideWithPassthrough('intl-date')
overrideWithPassthrough('intl-time')
Handsontable.cellTypes.registerCellType('intl-datetime', {
// IntlDatetimeEditor extends the (loosely-typed) text editor returned by
// getEditor('text'); cast to satisfy registerCellType's editor signature.
editor: IntlDatetimeEditor as any,
renderer: baseTextRenderer,
validator: datetimeValidator,
valueFormatter: isoPassthrough
valueFormatter: intlDatetimeValueFormatter
} as any)
}
@@ -17,8 +17,7 @@ import { DQRule, DQRuleTypes } from './models/dq-rules.model'
import { getDqDataCols } from './utils/getDqDataCols'
import { getNotNullDefault } from './utils/getNotNullDefault'
import { mergeColsRules } from './utils/mergeColsRules'
import { parseColTypeRow } from './utils/parseColTypeRow'
import { DELETE_RECORD_COLUMN_RULE } from './utils/deleteRecordColumnRule'
import { parseColType } from './utils/parseColType'
import { dqValidate } from './validations/dq-validation'
import {
datetimeValidator,
@@ -29,9 +28,6 @@ import { mapIntlCellTypes } from './utils/mapIntlCellTypes'
import { CustomAutocompleteEditor } from './editors/numericAutocomplete'
import { registerIntlCellTypes } from './cellTypes/intlCellTypes'
import { makeNumberFormatRenderer } from '../../editor/utils/renderers.utils'
import { makeRegexWarningRenderer } from '../../editor/utils/regex-warning-renderer'
import { isRegexRuleExempt } from './utils/isRegexRuleExempt'
import { parseRegexRule } from './utils/parseRegexRule'
export class DcValidator {
private rules: DcValidation[] = []
@@ -54,33 +50,7 @@ export class DcValidator {
this.sasparams = sasparams
this.hotInstance = hotInstance
// Each cols[i].COLTYPE is a single JSON-object string (one per column, as
// of the vertical-array getdata.sas fix — issue #253), not the old
// comma-joined sasparams.COLTYPE list. DELETE_RECORD_COLUMN_RULE is
// injected separately — see its own doc comment for why.
//
// COLHEADERS is the authoritative grid order and the only safe key to
// build against: it is the variable list of the *output* dataset, where
// getdata.sas hoists the delete column and the PK to the front ("order
// delete var and pk fields at start of table"), whereas cols[].VARNUM
// describes the *source* table and cols[] itself can arrive sorted by
// NAME. Those orders diverge whenever the PK isn't already the source's
// first column, so rules are keyed by name and emitted in COLHEADERS
// order — never paired positionally against cols[] or VARNUM.
//
// A column with no parseable COLTYPE falls back to a plain text rule
// rather than being dropped: getRules() is consumed positionally against
// COLHEADERS, so a missing entry would shift every later column.
const ruleByName = new Map<string, DcValidation>([
[DELETE_RECORD_COLUMN_RULE.data, { ...DELETE_RECORD_COLUMN_RULE }]
])
for (const col of cols) {
const rule = parseColTypeRow(col.COLTYPE)
if (rule) ruleByName.set(col.NAME, rule)
}
this.rules = sasparams.COLHEADERS.split(',').map(
(name) => ruleByName.get(name) ?? { data: name }
)
this.rules = parseColType(sasparams.COLTYPE)
this.rules = mergeColsRules(cols, this.rules, $dataFormats)
this.rules = applyNumericFormats(this.rules)
this.rules = mapIntlCellTypes(this.rules)
@@ -242,32 +212,6 @@ export class DcValidator {
return details
}
/**
* Whether a value fails a SOFTREGEX rule on the given column, if one
* exists. SOFTREGEX never goes through dqValidate/the cell validator (see
* setupValidations it's a display-only grid renderer instead), so the
* edit-record modal, which has no grid renderer to hook into, uses this
* directly to show the same warning outside the grid. HARDREGEX takes
* precedence when both rules apply to the column, matching the grid's own
* behaviour (setupValidations skips wiring the SOFTREGEX renderer there
* too) a dual-rule column should render red/blocked, never yellow.
*/
failsSoftRegex(col: string, value: any): boolean {
if (this.hasDqRules(col, ['HARDREGEX'])) return false
if (isRegexRuleExempt(value)) return false
const softRegexRule = this.dqrules.find(
(rule: DQRule) => rule.BASE_COL === col && rule.RULE_TYPE === 'SOFTREGEX'
)
if (!softRegexRule) return false
try {
return !parseRegexRule(softRegexRule.RULE_VALUE).test(value.toString())
} catch (e) {
return false
}
}
/**
* SOFTSELECT is not defined in DQ RULES
* This function fetches it's values and pushes in the DQ RULES array with SOFTSELECT type
@@ -424,28 +368,6 @@ export class DcValidator {
// editor/validator stay intact via `type`.
this.rules[i].numericFormat = undefined
}
// SOFTREGEX: display-only warning (yellow), never blocks submission —
// unlike HARDREGEX (see dq-validation.ts), which goes through the
// normal validator/dqValidate path instead. Last-wins against
// NUMBER_FORMAT if a column somehow carried both (not expected in
// practice — one formats numbers, the other pattern-matches text).
//
// HARDREGEX takes precedence when both apply to the same column: skip
// wiring this renderer entirely, so a failing value stays HOT's own
// red htInvalid (from HARDREGEX/dqValidate) rather than being
// visually overridden by this renderer's yellow dc-warning-cell.
if (
this.hasDqRules(ruleColName, ['SOFTREGEX']) &&
!this.hasDqRules(ruleColName, ['HARDREGEX'])
) {
const softRegexRule = this.getDqDetails(ruleColName).find(
(rule: DQRule) => rule.RULE_TYPE === 'SOFTREGEX'
)
this.rules[i].renderer = makeRegexWarningRenderer(
softRegexRule?.RULE_VALUE
)
}
}
const self = this
@@ -467,27 +389,6 @@ export class DcValidator {
const colType = self.rules[i].type || ''
let handsontableValid = null
// A row marked for delete is exempt from cell-level validation — its
// values are about to be removed. Two deliberate exceptions:
// - PK columns still validate: the key identifies WHICH record to
// delete, and blank/invalid PKs are also skipped by the separate
// duplicate-key check (validatePrimaryKeys), so this is the only
// net that would otherwise catch them.
// - Uniqueness checks are unaffected by construction: they run in a
// separate pass (editor.component.ts validatePrimaryKeys) that
// never goes through this validator.
const markedForDelete =
this.instance?.getDataAtRowProp?.(
this.row,
'_____DELETE__THIS__RECORD_____'
) === 'Yes'
if (markedForDelete && !self.primaryKeys.includes(col || '')) {
callback(true)
return
}
// We call handsontable predefined validators, if it returns false, we validate as FALSE
// If return is true, we validate with our custom DQ validations
@@ -639,11 +540,8 @@ export class DcValidator {
if (rule.clsRule === 'EDIT') {
rule.readOnly = false
// Guard the -1: splice(-1, 1) drops the LAST hidden column rather than
// no-op'ing, so an EDIT column that was never hidden would un-hide an
// unrelated one (e.g. a column hidden by a HIDDEN DQ rule).
const hiddenIndex = this.hiddenColumns.indexOf(index)
if (hiddenIndex !== -1) this.hiddenColumns.splice(hiddenIndex, 1)
this.hiddenColumns.splice(hiddenIndex, 1)
}
}
}
@@ -1,9 +1,10 @@
import Handsontable from 'handsontable'
import Core from 'handsontable/core'
export class CustomAutocompleteEditor
extends Handsontable.editors.AutocompleteEditor
{
constructor(instance: Handsontable.Core) {
constructor(instance: Core) {
super(instance)
}
@@ -12,9 +13,8 @@ export class CustomAutocompleteEditor
}
// Listbox open
// HOT 18's AutocompleteEditor.open() takes no arguments (was `event?: Event`).
open(): void {
super.open()
open(event?: Event | undefined): void {
super.open(event)
if (this.isCellNumeric()) {
this.htContainer.classList.add('numericListbox')
@@ -9,8 +9,4 @@ export interface Col {
MEMLABEL: string
DESC: string
LONGDESC: string
// Handsontable column-formatting spec (JSON-object string), one per column
// — see parseColTypeRow.ts. Optional: getdata.sas LEFT JOINs this in, so
// an unmatched row can leave it unset.
COLTYPE?: string
}
@@ -18,5 +18,3 @@ export type DQRuleTypes =
| 'HIDDEN'
| 'ROUND'
| 'NUMBER_FORMAT'
| 'HARDREGEX'
| 'SOFTREGEX'
@@ -23,11 +23,11 @@ describe('DC Validator', () => {
// Check if COLS merged with FORMATS
expect(cols[0].TYPE).toEqual('char')
// Get all — one rule per cols[] entry, plus the injected
// DELETE_RECORD_COLUMN_RULE (never present in cols[], see its own
// doc comment)
// Get all
const validationRules = dcValidator.getRules()
expect(validationRules).toHaveSize(example_cols.length + 1)
expect(validationRules).toHaveSize(
JSON.parse(`[${example_COLTYPE}]`).length
)
// Get col with notnull validation
const someNumRule = dcValidator.getRule('SOME_NUM')
@@ -35,21 +35,26 @@ describe('DC Validator', () => {
expect(someNumRule?.allowEmpty).toBeFalse()
// SAS sends semantic date/time/datetime column types; the frontend maps them
// to the HOT 17 Intl cell-type names (see mapIntlCellTypes). The cell display
// is the raw stored ISO string (no locale/format meta) — see
// registerIntlCellTypes. The deprecated correctFormat + moment format strings
// are no longer sent in the payload.
// to the HOT 17 Intl cell types and attaches locale + Intl format meta so the
// cell display is localized (see mapIntlCellTypes). The deprecated
// correctFormat + moment format strings are no longer sent in the payload.
const someDateRule = dcValidator.getRule('SOME_DATE')
expect(someDateRule?.correctFormat).toBeUndefined()
expect(someDateRule?.type).toBe('intl-date')
expect(someDateRule?.dateFormat).toBeDefined()
expect((someDateRule as any)?.locale).toBeTruthy()
const someDatetimeRule = dcValidator.getRule('SOME_DATETIME')
expect(someDatetimeRule?.type).toBe('intl-datetime')
expect(someDatetimeRule?.correctFormat).toBeUndefined()
expect((someDatetimeRule as any)?.datetimeFormat).toBeDefined()
expect((someDatetimeRule as any)?.locale).toBeTruthy()
const someTimeRule = dcValidator.getRule('SOME_TIME')
expect(someTimeRule?.type).toBe('intl-time')
expect(someTimeRule?.correctFormat).toBeUndefined()
expect((someTimeRule as any)?.timeFormat).toBeDefined()
expect((someTimeRule as any)?.locale).toBeTruthy()
// Get col with dropdown, check if merging sas data is correct
const someDropdownRule = dcValidator.getRule('SOME_DROPDOWN')
@@ -66,15 +71,6 @@ describe('DC Validator', () => {
const someDropdownHardRule = dcValidator.getRule('SOME_DROPDOWN_HARD')
// Check strict - it is hardselect so strict should be true
expect(someDropdownHardRule?.strict).toBeTrue()
// _____DELETE__THIS__RECORD_____ never has a cols[] entry (%mp_getcols
// doesn't know about it — see DELETE_RECORD_COLUMN_RULE), but its rule
// must still exist with its fixed dropdown shape, independent of cols
const deleteRecordRule = dcValidator.getRule(
'_____DELETE__THIS__RECORD_____'
)
expect(deleteRecordRule?.type).toEqual('dropdown')
expect(deleteRecordRule?.source).toEqual(['No', 'Yes'])
})
it('should create an instance of validator and execute its functions', () => {
@@ -287,471 +283,8 @@ describe('DC Validator', () => {
expect(dcValidator.getRoundDigits('SOME_SHORTNUM')).toEqual(2)
expect(dcValidator.getRoundDigits('SOME_NUM')).toBeUndefined()
})
it('5 | orders rules by COLHEADERS when cols[] arrives in a different order', () => {
// %mp_getcols/cols1 can serialize cols[] alphabetically by NAME rather
// than in COLHEADERS order (confirmed against a real getdata.sas
// response). getRules() is consumed positionally against headerColumns,
// so rule order must follow COLHEADERS regardless of cols[]'s own order.
const alphabeticalCols = [...example_cols].sort((a, b) =>
a.NAME.localeCompare(b.NAME)
)
const dcValidator: DcValidator = new DcValidator(
example_sasparams,
example_dataformats,
alphabeticalCols,
example_dqRules,
example_dqData
)
expect(dcValidator.getRules().map((rule) => rule.data)).toEqual(
example_sasparams.COLHEADERS.split(',')
)
})
it("6 | keeps rules aligned with COLHEADERS when the PK is not the source table's first column", () => {
// Regression test for the real-world misalignment: getdata.sas hoists the
// delete column and PK to the front of the *output* dataset ("order
// delete var and pk fields at start of table"), so COLHEADERS order and
// cols[].VARNUM order (which describes the *source* table) diverge
// whenever the PK isn't already the source's first column. Worst case (as
// seen in the wild): the PK is the source's LAST column, so pairing rules
// to headers by VARNUM shifts every single column by one.
const cols: Col[] = [
// deliberately alphabetical, as the backend serializes it
makeCol('PRIMARY_KEY_FIELD', 6), // the PK — last in the source table
makeCol('SOME_CHAR', 1),
makeCol('SOME_DATE', 2),
makeCol('SOME_DROPDOWN', 3),
makeCol('SOME_NUM', 4),
makeCol('SOME_TIME', 5)
]
const sasparams: SASParam = {
...example_sasparams,
// PK hoisted to the front by getdata.sas, ahead of VARNUM 1..5
COLHEADERS:
'_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,SOME_CHAR,' +
'SOME_DATE,SOME_DROPDOWN,SOME_NUM,SOME_TIME',
PK: 'PRIMARY_KEY_FIELD'
} as SASParam
const dcValidator: DcValidator = new DcValidator(
sasparams,
{ vars: {} } as $DataFormats,
cols,
[],
[]
)
expect(dcValidator.getRules().map((rule) => rule.data)).toEqual(
sasparams.COLHEADERS.split(',')
)
})
it('7 | falls back to a text rule rather than dropping a column with an unparseable COLTYPE', () => {
// A dropped rule would shorten getRules() and shift every later column
// against headerColumns. getdata.sas LEFT JOINs COLTYPE in, so an
// unmatched row can legitimately leave it unset.
const cols: Col[] = [
makeCol('PRIMARY_KEY_FIELD', 1),
{ ...makeCol('SOME_CHAR', 2), COLTYPE: undefined },
{ ...makeCol('SOME_NUM', 3), COLTYPE: 'not valid json' }
]
const sasparams: SASParam = {
...example_sasparams,
COLHEADERS:
'_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,SOME_CHAR,SOME_NUM',
PK: 'PRIMARY_KEY_FIELD'
} as SASParam
const dcValidator: DcValidator = new DcValidator(
sasparams,
{ vars: {} } as $DataFormats,
cols,
[],
[]
)
expect(dcValidator.getRules().map((rule) => rule.data)).toEqual(
sasparams.COLHEADERS.split(',')
)
})
it('8 | does not un-hide an unrelated column when a CLS EDIT column was never hidden', () => {
// hiddenColumns.indexOf() returns -1 for a column that isn't hidden, and
// splice(-1, 1) drops the LAST element instead of no-op'ing — so an EDIT
// column used to silently un-hide a column hidden by a HIDDEN DQ rule.
//
// The EDIT column must not be the PK: setColumnLevelSecurity forces PK
// columns to clsRule 'READ' before the EDIT branch is reached, which would
// skip the splice entirely and leave this test passing against the bug.
const cols: Col[] = [
{ ...makeCol('PK_COL', 1), CLS_RULE: 'READ' },
{ ...makeCol('SECRET_COL', 2), CLS_RULE: 'READ' },
{ ...makeCol('OPEN_COL', 3), CLS_RULE: 'EDIT' }
]
const sasparams: SASParam = {
...example_sasparams,
CLS_FLAG: 1,
COLHEADERS: '_____DELETE__THIS__RECORD_____,PK_COL,SECRET_COL,OPEN_COL',
PK: 'PK_COL'
} as SASParam
const dqRules: DQRule[] = [
{ BASE_COL: 'SECRET_COL', RULE_TYPE: 'HIDDEN', RULE_VALUE: '', X: 0 }
]
const dcValidator: DcValidator = new DcValidator(
sasparams,
{ vars: {} } as $DataFormats,
cols,
dqRules,
[]
)
// SECRET_COL is rules[2]; CLS_FLAG also hides the delete column (index 0).
// OPEN_COL (rules[3]) is EDIT and was never hidden, so the splice guard is
// what keeps SECRET_COL hidden here.
expect(dcValidator.getHiddenColumns()).toContain(2)
})
describe('9 | ignores cell validation on rows marked for delete', () => {
// An invalid cell shouldn't block submission if its row
// is about to be deleted anyway. The PK is exempt from the exemption — it
// identifies WHICH record to delete, and a blank/invalid PK on a delete
// row would also slip past the separate uniqueness check (which skips
// blank PKs).
//
// The validator is a plain `function` invoked with cellProperties as
// `this` (row + instance), so a minimal fake stands in for a real HOT
// instance here — only `instance.getDataAtRowProp` is read by the guard.
const fakeCellProps = (markedForDelete: boolean): any => ({
row: 0,
editor: undefined,
instance: {
getDataAtRowProp: (_row: number, prop: string) =>
prop === '_____DELETE__THIS__RECORD_____'
? markedForDelete
? 'Yes'
: 'No'
: undefined
}
})
const buildValidator = () =>
new DcValidator(
example_sasparams,
example_dataformats,
example_cols,
example_dqRules,
example_dqData
)
it('exempts an invalid non-PK cell on a delete-marked row', () => {
const dcValidator = buildValidator()
const someNumRule = dcValidator.getRule('SOME_NUM')
someNumRule!.validator!.call(
fakeCellProps(true),
'not a number',
(valid: boolean) => {
expect(valid).toBeTrue()
}
)
})
it('exempts a NOTNULL-empty non-PK cell on a delete-marked row', () => {
const dcValidator = buildValidator()
const someNumRule = dcValidator.getRule('SOME_NUM')
someNumRule!.validator!.call(
fakeCellProps(true),
null,
(valid: boolean) => {
expect(valid).toBeTrue()
}
)
})
it('still rejects an invalid non-PK cell when the row is not marked for delete', () => {
const dcValidator = buildValidator()
const someNumRule = dcValidator.getRule('SOME_NUM')
someNumRule!.validator!.call(
fakeCellProps(false),
'not a number',
(valid: boolean) => {
expect(valid).toBeFalse()
}
)
})
it('still accepts a valid non-PK cell when the row is not marked for delete', () => {
const dcValidator = buildValidator()
const someNumRule = dcValidator.getRule('SOME_NUM')
someNumRule!.validator!.call(
fakeCellProps(false),
2,
(valid: boolean) => {
expect(valid).toBeTrue()
}
)
})
it('still rejects an invalid PK cell even when the row is marked for delete', () => {
const dcValidator = buildValidator()
const pkRule = dcValidator.getRule('PRIMARY_KEY_FIELD')
pkRule!.validator!.call(
fakeCellProps(true),
'not a number',
(valid: boolean) => {
expect(valid).toBeFalse()
}
)
})
it('treats the edit-record modal path (dummy HOT instance, no data) as not marked for delete', () => {
const dcValidator = buildValidator()
const someNumRule = dcValidator.getRule('SOME_NUM')
// executeHotValidator builds cellProperties against a real but
// data-less Handsontable instance — getDataAtRowProp() there returns
// undefined, never 'Yes', so the exemption never applies in the modal.
dcValidator.executeHotValidator(someNumRule!, null, (valid: boolean) => {
expect(valid).toBeFalse()
})
})
})
it('10 | blocks submission of a value that fails a HARDREGEX rule', () => {
// SOME_CHAR already has its own CASE=UPCASE rule in example_dqRules —
// 'AB1' passes that (it equals its own uppercase form) but fails a
// letters-only HARDREGEX pattern, isolating HARDREGEX's own effect
// rather than piggybacking on CASE rejecting the value too.
const dcValidator: DcValidator = new DcValidator(
example_sasparams,
example_dataformats,
example_cols,
[
...example_dqRules,
{
BASE_COL: 'SOME_CHAR',
RULE_TYPE: 'HARDREGEX',
RULE_VALUE: '^[A-Z]+$',
X: 0
}
],
example_dqData
)
const someCharRule = dcValidator.getRule('SOME_CHAR')
dcValidator.executeHotValidator(someCharRule!, 'ABC', (valid: boolean) => {
expect(valid).toBeTrue()
})
dcValidator.executeHotValidator(someCharRule!, 'AB1', (valid: boolean) => {
expect(valid).toBeFalse()
})
})
it('11 | wires a function renderer for a SOFTREGEX rule, without blocking submission', () => {
// SOME_CHAR_ANY carries no other DQ rules in the shared fixture, so this
// isolates SOFTREGEX's own wiring. The renderer's own pass/fail/delete-
// suppression behaviour is covered by regex-warning-renderer.spec.ts —
// this only proves setupValidations() assigns it and that, unlike
// HARDREGEX, a non-matching value still submits.
const dcValidator: DcValidator = new DcValidator(
example_sasparams,
example_dataformats,
example_cols,
[
...example_dqRules,
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'SOFTREGEX',
RULE_VALUE: '^[A-Z]+$',
X: 0
}
],
example_dqData
)
const someCharAnyRule = dcValidator.getRule('SOME_CHAR_ANY')
expect(typeof someCharAnyRule?.renderer).toEqual('function')
dcValidator.executeHotValidator(
someCharAnyRule!,
'not uppercase',
(valid: boolean) => {
expect(valid).toBeTrue()
}
)
})
it('12 | HARDREGEX takes precedence over SOFTREGEX on a dual-rule column', () => {
// Both rules on the same column: HARDREGEX must win, so the cell
// renders red (blocked) via HOT's own htInvalid, not yellow. No
// SOFTREGEX renderer is wired at all here, so there's nothing that could
// visually compete with htInvalid for that column.
const dcValidator: DcValidator = new DcValidator(
example_sasparams,
example_dataformats,
example_cols,
[
...example_dqRules,
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'HARDREGEX',
RULE_VALUE: '^[A-Z]+$',
X: 0
},
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'SOFTREGEX',
RULE_VALUE: '^[A-Z]+$',
X: 0
}
],
example_dqData
)
const rule = dcValidator.getRule('SOME_CHAR_ANY')
dcValidator.executeHotValidator(
rule!,
'not uppercase',
(valid: boolean) => {
expect(valid).toBeFalse()
}
)
expect(rule?.renderer).toBeUndefined()
})
describe('13 | failsSoftRegex (edit-record modal support for SOFTREGEX)', () => {
// SOFTREGEX never goes through dqValidate (see the wiring in
// setupValidations), so the edit-record modal — which has no grid
// renderer to hook into — calls this directly to show the same warning.
const buildValidator = (dqRules: DQRule[]) =>
new DcValidator(
example_sasparams,
example_dataformats,
example_cols,
dqRules,
example_dqData
)
it('is true for a value that fails the pattern', () => {
const dcValidator = buildValidator([
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'SOFTREGEX',
RULE_VALUE: '^[A-Z]+$',
X: 0
}
])
expect(
dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'lowercase')
).toBeTrue()
})
it('is false for a value that matches the pattern', () => {
const dcValidator = buildValidator([
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'SOFTREGEX',
RULE_VALUE: '^[A-Z]+$',
X: 0
}
])
expect(
dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'UPPERCASE')
).toBeFalse()
})
it('handles a PRX-delimited pattern with a case-insensitive flag, as authored for prxparse', () => {
const dcValidator = buildValidator([
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'SOFTREGEX',
RULE_VALUE: '/\\b(the|data)\\b/i',
X: 0
}
])
expect(
dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'this is dummy data')
).toBeFalse()
expect(
dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'THE WIND WAS BLOWING')
).toBeFalse()
expect(
dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'nothing relevant here')
).toBeTrue()
})
it('is false for a column with no SOFTREGEX rule', () => {
const dcValidator = buildValidator([])
expect(
dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'anything')
).toBeFalse()
})
it('is false for blank/special-missing values, same exemption as HARDREGEX', () => {
const dcValidator = buildValidator([
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'SOFTREGEX',
RULE_VALUE: '^[A-Z]+$',
X: 0
}
])
expect(dcValidator.failsSoftRegex('SOME_CHAR_ANY', '')).toBeFalse()
expect(dcValidator.failsSoftRegex('SOME_CHAR_ANY', '.a')).toBeFalse()
})
it('is false when the column also has HARDREGEX (precedence — no yellow on a red cell)', () => {
const dcValidator = buildValidator([
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'HARDREGEX',
RULE_VALUE: '^[A-Z]+$',
X: 0
},
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'SOFTREGEX',
RULE_VALUE: '^[A-Z]+$',
X: 0
}
])
expect(
dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'lowercase')
).toBeFalse()
})
})
})
/** Minimal cols[] entry — only the fields rule ordering depends on. */
const makeCol = (name: string, varnum: number): Col =>
({
NAME: name,
VARNUM: varnum,
LABEL: name,
FMTNAME: '',
DDTYPE: 'CHARACTER',
TYPE: '',
CLS_RULE: 'READ',
MEMLABEL: '',
DESC: '',
LONGDESC: '',
COLTYPE: `{"data":"${name}"}`
}) as Col
const example_dqData = [
{
BASE_COL: 'SOME_DROPDOWN',
@@ -855,13 +388,6 @@ const example_dqRules: any = [
}
]
// One row per column, each carrying its own COLTYPE JSON-object string — the
// vertical-array shape from issue #253's getdata.sas fix. Order: SOME_DROPDOWN
// then SOME_NUM first (the `cols[0].TYPE` assertion above is positional),
// remaining columns in the same order the old concatenated
// sasparams.COLTYPE used to list them. _____DELETE__THIS__RECORD_____ is
// deliberately absent — DcValidator injects its rule directly (see
// DELETE_RECORD_COLUMN_RULE), since %mp_getcols never has a row for it.
const example_cols = [
{
CLS_RULE: 'READ',
@@ -873,8 +399,7 @@ const example_cols = [
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_DROPDOWN',
VARNUM: 3,
COLTYPE: '{"data":"SOME_DROPDOWN"}'
VARNUM: 3
},
{
CLS_RULE: 'READ',
@@ -886,157 +411,77 @@ const example_cols = [
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_NUM',
VARNUM: 4,
COLTYPE: '{"data":"SOME_NUM","type":"numeric","format":"0"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'NUMERIC',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'PRIMARY_KEY_FIELD',
LONGDESC: '',
MEMLABEL: '',
NAME: 'PRIMARY_KEY_FIELD',
VARNUM: 1,
COLTYPE: '{"data":"PRIMARY_KEY_FIELD","type":"numeric","format":"0"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'CHARACTER',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'SOME_CHAR',
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_CHAR',
VARNUM: 2,
COLTYPE: '{"data":"SOME_CHAR"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'CHARACTER',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'SOME_CHAR_LOW',
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_CHAR_LOW',
VARNUM: 5,
COLTYPE: '{"data":"SOME_CHAR_LOW"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'CHARACTER',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'SOME_CHAR_ANY',
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_CHAR_ANY',
VARNUM: 6,
COLTYPE: '{"data":"SOME_CHAR_ANY"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'CHARACTER',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'SOME_DROPDOWN_HARD',
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_DROPDOWN_HARD',
VARNUM: 7,
COLTYPE: '{"data":"SOME_DROPDOWN_HARD"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'DATE',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'SOME_DATE',
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_DATE',
VARNUM: 8,
COLTYPE: '{"data":"SOME_DATE","type":"date"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'DATETIME',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'SOME_DATETIME',
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_DATETIME',
VARNUM: 9,
COLTYPE: '{"data":"SOME_DATETIME","type":"datetime"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'TIME',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'SOME_TIME',
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_TIME',
VARNUM: 10,
COLTYPE: '{"data":"SOME_TIME","type":"time"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'NUMERIC',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'SOME_SHORTNUM',
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_SHORTNUM',
VARNUM: 11,
COLTYPE: '{"data":"SOME_SHORTNUM","type":"numeric","format":"0"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'NUMERIC',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'SOME_BESTNUM',
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_BESTNUM',
VARNUM: 12,
COLTYPE: '{"data":"SOME_BESTNUM","type":"numeric","format":"0"}'
VARNUM: 4
}
]
// COLHEADERS mirrors a real getdata.sas response: the *output* dataset's
// variable list, i.e. the delete column and PK hoisted to the front, then the
// remaining source columns in VARNUM order. It drives rule order, so it must
// list every example_cols entry.
const example_COLTYPE = `
{
"data":"_____DELETE__THIS__RECORD_____",
"type":"dropdown",
"source":[
"No",
"Yes"
]
},
{
"data":"PRIMARY_KEY_FIELD",
"type":"numeric",
"format":"0"
},
{
"data":"SOME_CHAR"
},
{
"data":"SOME_CHAR_LOW"
},
{
"data":"SOME_CHAR_ANY"
},
{
"data":"SOME_DROPDOWN"
},
{
"data":"SOME_DROPDOWN_HARD"
},
{
"data":"SOME_NUM",
"type":"numeric",
"format":"0"
},
{
"data":"SOME_DATE",
"type":"date"
},
{
"data":"SOME_DATETIME",
"type":"datetime"
},
{
"data":"SOME_TIME",
"type":"time"
},
{
"data":"SOME_SHORTNUM",
"type":"numeric",
"format":"0"
},
{
"data":"SOME_BESTNUM",
"type":"numeric",
"format":"0"
}`
const example_sasparams = {
CLS_FLAG: 0,
COLHEADERS:
'_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,SOME_CHAR,SOME_DROPDOWN,' +
'SOME_NUM,SOME_CHAR_LOW,SOME_CHAR_ANY,SOME_DROPDOWN_HARD,SOME_DATE,' +
'SOME_DATETIME,SOME_TIME,SOME_SHORTNUM,SOME_BESTNUM',
COLHEADERS: 'head1 head2',
COLTYPE: example_COLTYPE,
DTTMVARS: 'dttm vars',
DTVARS: 'dt vars',
FILTER_TEXT: 'filter text',
LOADTYPE: 'load type',
PK: 'PRIMARY_KEY_FIELD',
PKCNT: 1,
PK: 'primary key',
PKCNT: 0,
RK_FLAG: 0,
TMVARS: 'string'
}
@@ -1067,24 +512,6 @@ const example_dataformats = {
length: '128',
type: 'char'
},
SOME_CHAR_LOW: {
format: '$128.',
label: 'SOME_CHAR_LOW',
length: '128',
type: 'char'
},
SOME_CHAR_ANY: {
format: '$128.',
label: 'SOME_CHAR_ANY',
length: '128',
type: 'char'
},
SOME_DROPDOWN_HARD: {
format: '$128.',
label: 'SOME_DROPDOWN_HARD',
length: '128',
type: 'char'
},
SOME_NUM: {
format: 'best.',
label: 'SOME_NUM',
@@ -0,0 +1,23 @@
import { HotColumnSettings } from '../models/dc-validation.model'
import { parseColType } from '../utils/parseColType'
describe('DC Validator - parse col type', () => {
it('should return array of parsed json', () => {
const colTypeString =
'{"data":"test","test2":"test2"}, {"data":"test3","test4":"test4"}'
const expected: HotColumnSettings[] = [
{ data: 'test', test2: 'test2' },
{ data: 'test3', test4: 'test4' }
]
expect(parseColType(colTypeString)).toEqual(expected)
})
it('should return empty array for invalid json', () => {
const colTypeString =
'{"test":"test""test:"test2"}, {"test3":"test3","test4":"test4"}'
const expected: HotColumnSettings[] = []
expect(parseColType(colTypeString)).toEqual(expected)
})
})
@@ -1,24 +0,0 @@
import { HotColumnSettings } from '../models/dc-validation.model'
import { parseColTypeRow } from '../utils/parseColTypeRow'
describe('DC Validator - parse col type row', () => {
it('parses a single JSON object', () => {
const expected: HotColumnSettings = { data: 'test', type: 'numeric' }
expect(parseColTypeRow('{"data":"test","type":"numeric"}')).toEqual(
expected
)
})
it('returns undefined for undefined input (LEFT JOIN can leave it unset)', () => {
expect(parseColTypeRow(undefined)).toBeUndefined()
})
it('returns undefined for an empty string', () => {
expect(parseColTypeRow('')).toBeUndefined()
})
it('returns undefined for malformed JSON, without throwing', () => {
expect(parseColTypeRow('{not valid')).toBeUndefined()
})
})
@@ -1,16 +0,0 @@
import { DcValidation } from '../models/dc-validation.model'
/**
* `_____DELETE__THIS__RECORD_____` is a client-side UI concept (the delete
* checkbox column) that getdata.sas synthesizes into vars3/vars4 for display
* purposes only it's never a real column of the underlying dataset, so
* `%mp_getcols` (which builds `cols1`, the left side of the join into
* `cols`) never has a row for it, and its rule can no longer travel via
* `cols[].COLTYPE` (see issue #253's vertical-array fix). The rule is fixed
* and data-independent, so it's injected directly here instead.
*/
export const DELETE_RECORD_COLUMN_RULE: DcValidation = {
data: '_____DELETE__THIS__RECORD_____',
type: 'dropdown',
source: ['No', 'Yes']
}
@@ -1,19 +0,0 @@
import { isRegexRuleExempt } from './isRegexRuleExempt'
describe('isRegexRuleExempt', () => {
it('exempts blank/undefined/null', () => {
expect(isRegexRuleExempt('')).toBeTrue()
expect(isRegexRuleExempt(undefined)).toBeTrue()
expect(isRegexRuleExempt(null)).toBeTrue()
})
it('exempts SAS special missing values', () => {
expect(isRegexRuleExempt('.')).toBeTrue()
expect(isRegexRuleExempt('.a')).toBeTrue()
expect(isRegexRuleExempt('_')).toBeTrue()
})
it('does not exempt an ordinary value', () => {
expect(isRegexRuleExempt('ABC123')).toBeFalse()
})
})
@@ -1,13 +0,0 @@
import { isSpecialMissing } from '@sasjs/utils/input/validators'
/**
* HARDREGEX/SOFTREGEX both skip pattern-matching for blank and SAS special
* missing values (., .a-.z, _) the same exemption NOTNULL/MINVAL/MAXVAL
* already apply elsewhere, since neither convention represents a real
* formatted value the pattern is meant to check.
*/
export const isRegexRuleExempt = (value: any): boolean => {
if (value === undefined || value === null || value === '') return true
return isSpecialMissing(value)
}
@@ -2,31 +2,58 @@ import { DcValidation } from '../models/dc-validation.model'
/**
* Maps SAS' semantic date/time/datetime column types to the HOT 17 Intl cell
* type names. SAS sends a HOT-agnostic flag (`date`/`time`/`datetime`); the
* frontend owns the HOT-specific cell-type names:
* types and attaches the locale + Intl format meta that drive their display.
*
* - `date` `intl-date` (native date picker)
* - `time` `intl-time` (native time picker)
* - `datetime` `intl-datetime` (native datetime-local picker)
* SAS sends a HOT-agnostic flag (`date`/`time`/`datetime`); the frontend owns
* the HOT-specific cell-type names and the locale-aware rendering:
*
* The cell display is the raw stored ISO string (locale-independent) see
* registerIntlCellTypes, where these types' `valueFormatter` is the identity
* passthrough. No locale/format meta is attached; it is unused for display, and
* the validators are ISO-based and locale-agnostic. Runs in the DcValidator
* constructor, before the rules reach Handsontable.
* - `date` `intl-date` (native date picker; display via Intl `dateFormat`)
* - `time` `intl-time` (native time picker; display via Intl `timeFormat`)
* - `datetime` `intl-datetime` (native datetime-local picker; display via `datetimeFormat`)
*
* The cell display is localized to `navigator.language` (the browser/OS locale);
* the stored/submitted value stays the raw ISO string. Second precision is kept
* for time/datetime so SAS time/datetime values are not truncated. Runs in the
* DcValidator constructor, before the rules reach Handsontable. See
* registerIntlCellTypes for the editors/validators these resolve to.
*
* @param rules Cell validation rules passed to Handsontable's `columns`.
*/
const INTL_TYPE_MAP: Record<string, string> = {
date: 'intl-date',
time: 'intl-time',
datetime: 'intl-datetime'
const DATE_FORMAT: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: '2-digit',
day: '2-digit'
}
const TIME_FORMAT: Intl.DateTimeFormatOptions = {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}
export const DATETIME_FORMAT: Intl.DateTimeFormatOptions = {
...DATE_FORMAT,
...TIME_FORMAT
}
const INTL_TYPE_MAP: Record<
string,
{ type: string; meta: Record<string, unknown> }
> = {
date: { type: 'intl-date', meta: { dateFormat: DATE_FORMAT } },
time: { type: 'intl-time', meta: { timeFormat: TIME_FORMAT } },
datetime: { type: 'intl-datetime', meta: { datetimeFormat: DATETIME_FORMAT } }
}
export const mapIntlCellTypes = (rules: DcValidation[]): DcValidation[] => {
for (const rule of rules) {
const mapped = rule.type ? INTL_TYPE_MAP[rule.type as string] : undefined
if (mapped) rule.type = mapped
const cfg = rule.type ? INTL_TYPE_MAP[rule.type as string] : undefined
if (!cfg) continue
rule.type = cfg.type
const meta = rule as Record<string, unknown>
meta.locale = window.navigator.language
Object.assign(meta, cfg.meta)
}
return rules
@@ -27,7 +27,7 @@ export const mergeColsRules = (
: colFormats.type
if (rule && col.DESC) rule.desc = col.DESC
if (rule && colFormats?.length) rule.length = parseInt(colFormats.length)
if (rule && colFormats.length) rule.length = parseInt(colFormats.length)
if (rule && col.CLS_RULE) rule.clsRule = col.CLS_RULE
if (rule && colFormats?.type) rule.sasType = colFormats.type
}
@@ -0,0 +1,16 @@
import { HotColumnSettings } from '../models/dc-validation.model'
/**
* From sas we get a string instead of array of objects, in that string `[]` are missing so
* before parsing JSON we need to add them.
*
* @param coltype string (objects) that comes from sas
* @returns JSON Handsontable.ColumnSettings[]
*/
export const parseColType = (coltype: string): HotColumnSettings[] => {
try {
return JSON.parse(`[${coltype}]`)
} catch (err: any) {
return []
}
}
@@ -1,23 +0,0 @@
import { HotColumnSettings } from '../models/dc-validation.model'
/**
* Parses a single column's `COLTYPE` JSON-object string (one per `cols` row,
* as of the vertical-array getdata.sas fix see issue #253). Unlike the
* previous comma-joined `sasparams.COLTYPE` format, each row is independent:
* a malformed or missing value here only drops that one column's rule,
* rather than every column's.
*
* @param coltype a single column's COLTYPE JSON string, or undefined (the
* LEFT JOIN in getdata.sas can leave it unset for unmatched rows)
*/
export const parseColTypeRow = (
coltype: string | undefined
): HotColumnSettings | undefined => {
if (!coltype) return undefined
try {
return JSON.parse(coltype)
} catch (err: any) {
return undefined
}
}
@@ -1,237 +0,0 @@
import { parseRegexRule } from './parseRegexRule'
describe('parseRegexRule', () => {
it('treats a bare pattern (no delimiters) exactly as before', () => {
const regex = parseRegexRule('^[A-Z]{3}\\d{4}$')
expect(regex.test('ABC1234')).toBeTrue()
expect(regex.test('abc1234')).toBeFalse()
})
it('parses a delimited pattern with no flags, case-sensitively', () => {
const regex = parseRegexRule('/\\b(the|data)\\b/')
expect(regex.test('this is dummy data')).toBeTrue()
expect(regex.test('THE WIND WAS BLOWING')).toBeFalse()
})
it('parses a delimited pattern with the i flag, case-insensitively', () => {
const regex = parseRegexRule('/\\b(the|data)\\b/i')
expect(regex.test('THIS IS DUMMY DATA')).toBeTrue()
expect(regex.test('The wind was blowing')).toBeTrue()
expect(regex.test('nothing relevant here')).toBeFalse()
})
it('extracts source and flags correctly from a delimited pattern', () => {
const regex = parseRegexRule('/^[A-Z]+$/g')
expect(regex.source).toEqual('^[A-Z]+$')
expect(regex.flags).toEqual('g')
})
it('treats a bare pattern containing an internal slash as a literal, not a delimited form', () => {
const regex = parseRegexRule('a/b')
expect(regex.test('a/b')).toBeTrue()
expect(regex.test('ab')).toBeFalse()
})
it('throws for an unterminated delimited pattern, same as a bad bare pattern would', () => {
expect(() => parseRegexRule('/[unterminated/')).toThrow()
})
// parseRegexRule strips PRX's /pattern/flags delimiter wrapper and
// translates the three PRX-only constructs below to a JS equivalent. These
// three were picked because they're mechanical, low-risk translations with
// one unambiguous JS equivalent. Atomic groups and possessive quantifiers are
// NOT translated - both would need a lookahead+backreference rewrite that
// changes the pattern's capture-group numbering, which is a real
// correctness risk for comparatively rare constructs, so they're left as a
// documented limitation below instead.
describe('translated PRX-only syntax', () => {
it('hoists a leading (?i) inline modifier into the i flag', () => {
const regex = parseRegexRule('/(?i)abc/')
expect(regex.test('ABC')).toBeTrue()
expect(regex.test('abc')).toBeTrue()
})
it('merges a leading (?i) with flags already present after the closing delimiter', () => {
const regex = parseRegexRule('/(?i)^[a-z]+$/g')
expect(regex.flags).toContain('i')
expect(regex.flags).toContain('g')
expect(regex.test('ABC')).toBeTrue()
})
it('does not add a duplicate i flag when (?i) and the trailing /i are both present', () => {
expect(() => parseRegexRule('/(?i)abc/i')).not.toThrow()
})
it('treats \\Q...\\E as a literal, metacharacter-escaped sequence', () => {
const regex = parseRegexRule('/\\Qa+b*c\\E/')
expect(regex.test('a+b*c')).toBeTrue()
// Without the \Q...\E escaping, '+' and '*' would be quantifiers, so
// this would also match a literal "c" alone. It shouldn't.
expect(regex.test('c')).toBeFalse()
})
it('translates \\A/\\z to absolute start/end anchors equivalent to PRX semantics', () => {
const regex = parseRegexRule('/\\Afoo\\z/')
expect(regex.test('foo')).toBeTrue()
expect(regex.test('foobar')).toBeFalse()
expect(regex.test('barfoo')).toBeFalse()
// \z (unlike JS's own $) does not match immediately before a
// trailing newline - this is stricter, matching PRX/Perl semantics.
expect(regex.test('foo\n')).toBeFalse()
})
})
// Pre-existing gaps, not regressions from this fix - both fail safely:
// they throw, same as any other malformed pattern, and each call site
// already treats a throw as "always valid" / "never warn".
describe('known limitations - PRX-only syntax not translated', () => {
it('throws on free-spacing/extended (/x) mode (valid PRX, not a JS flag)', () => {
expect(() => parseRegexRule('/a+ \\s+ b/x')).toThrow()
})
it('throws on an atomic group (?>...) (valid PRX, unsupported in JS)', () => {
expect(() => parseRegexRule('/(?>a|ab)c/')).toThrow()
})
it('throws on a possessive quantifier, e.g. a++ (valid PRX, unsupported in JS)', () => {
expect(() => parseRegexRule('/a++b/')).toThrow()
})
})
// HARDREGEX/SOFTREGEX RULE_VALUEs collected from an existing
// MPE_VALIDATIONS table. None of these use PRX-only syntax (no inline
// (?i), \Q...\E, \A/\z, atomic groups, or possessive quantifiers), so
// they pass through parseRegexRule with only the /pattern/flags
// delimiter stripped - the same PCRE-compatible escape rules apply on
// both the SAS PRX side and the JS side, so behavior here is verified
// to match, not just "doesn't throw".
describe('RULE_VALUE corpus collected from an existing MPE_VALIDATIONS table', () => {
const collectedRuleValues = [
'/^-?[0-9]{1,13}([.]{0,1}[0-9]{0,3})$/',
'/^-?[0-9]{1,3}$/',
'/^(?:(?!31\\\\.12).)*$/',
'/^([0-9]{1,3}([.][0-9]{1,7})?)%$/',
'/^(100|[0-9]{1,2})%$/',
'/^[-,0-9]{1,13}([.]{0,1}[0-9]{0,3})$/',
'/^[-0-9]{1,19}([.]{0,1}[0-9]{0,3})$/',
'/^[-0-9]*([.]{0,1}[0-9]{0,2})$/',
'/^[-0-9]*([.]{0,1}[0-9]{0,3})$/',
'/^[-0-9]*([.]{0,1}[0-9]*)$/',
'/^[-0-9]*$/',
'/^[0-2]$/',
'/^[0-3]$/',
'/^[0-9]{1,19}([.]{0,1}[0-9]{0,3})$/',
'/^[0-9]{1,36}$/',
'/^[0-9]{1,72}$/',
'/^[0-9]{4}$/',
'/^[0-9]{5}$/',
'/^[0-9]*([.]{0,1}[0-9]{0,4})$/',
'/^[0,1]{1}([.]{0,1}[0-9]{0,4})$/',
'/^[1]{1,1}$/',
'/^[A-Z0-1]{5,5}$/',
'/^[a-zA-Z]{1,1}$/',
'/^[a-zA-Z]{1,3}$/',
'/^[a-zA-Z0-9_]{0,36}$/',
'/^[a-zA-Z0-9_=@\\/.:;,= -]{1,8}$/',
'/^[a-zA-Z0-9@_-]{1,100}$/',
'/^[a-zA-Z0-9@_-]{1,50}$/',
'/^[a-zA-Z0-9=@.:;,= -_]{1,50}$/',
'/^[a-zA-Z0-9=@.:;,= -_]{1,80}$/',
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,10}$/',
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,100}$/',
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,12}$/',
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,14}$/',
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,15}$/',
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,20}$/',
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,3}$/',
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,300}$/',
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,32}$/',
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,4}$/',
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,40}$/',
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,400}$/',
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,50}$/',
'/^[a-zA-Z0-9=@\\/.:;,= \\-_]{1,500}$/',
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,510}$/',
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,6}$/',
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,72}$/',
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,8}$/',
"/^[a-zA-Z0-9=@\\/.:;,= -_`']{1,100}$/",
"/^[a-zA-Z0-9=@\\/.:;,= -_`']{1,20}$/",
"/^[a-zA-Z0-9=@\\/.:;,= \\-_`']{1,500}$/",
"/^[a-zA-Z0-9=@\\/.:;,= -_`']*$/"
]
collectedRuleValues.forEach((ruleValue) => {
it(`compiles without throwing: ${ruleValue}`, () => {
expect(() => parseRegexRule(ruleValue)).not.toThrow()
})
})
it('preserves the exact pattern body and applies no flags for a plain numeric-range rule', () => {
const regex = parseRegexRule('/^[0-9]{1,13}([.]{0,1}[0-9]{0,3})$/')
expect(regex.source).toEqual('^[0-9]{1,13}([.]{0,1}[0-9]{0,3})$')
expect(regex.flags).toEqual('')
expect(regex.test('1234.56')).toBeTrue()
expect(regex.test('abc')).toBeFalse()
})
it('handles the percentage-with-alternation rule (100 or 0-99, optionally with a decimal)', () => {
const regex = parseRegexRule('/^(100|[0-9]{1,2})%$/')
expect(regex.test('100%')).toBeTrue()
expect(regex.test('42%')).toBeTrue()
expect(regex.test('101%')).toBeFalse()
})
it('handles a character class with an escaped delimiter slash and a non-edge escaped hyphen', () => {
const regex = parseRegexRule('/^[a-zA-Z0-9=@\\/.:;,= \\-_]{1,500}$/')
expect(regex.test('a/b-c d.e:f;g,h=i')).toBeTrue()
expect(regex.test('has a # in it')).toBeFalse()
})
it('handles a character class with literal backtick/single-quote characters', () => {
const regex = parseRegexRule("/^[a-zA-Z0-9=@\\/.:;,= -_`']*$/")
expect(regex.test("O'Brien`s value")).toBeTrue()
// The unescaped space-to-underscore run (` -_`) in this class is a
// range (space is code point 32, underscore 95), not three literal
// characters - so it also matches '#' (35), which falls inside that
// range. Same behavior in SAS PRX (identical character-class rules),
// so this is a faithful translation, not a parseRegexRule bug.
expect(regex.test('has a # in it')).toBeTrue()
expect(regex.test('has a ~ in it')).toBeFalse()
})
// This is the one inconsistency in the whole corpus: every other escaped
// '/' in this file uses a single backslash, but this rule has \\. (two
// backslashes) where every other sign points to a single \. having been
// intended (a literal dot, inside a negative lookahead meant to exclude
// "31.12" - e.g. a 31-December date - from matching). SAS PRX and JS
// regex use identical backslash-escape semantics for this (no PRX-only
// construct is involved), so parseRegexRule's output is a faithful,
// parity-preserving translation of whatever was actually authored -
// this test pins down what the corpus *actually* does today, verbatim,
// not what was likely intended. \\ (escaped backslash) followed by an
// unescaped . (any character) means the lookahead is functionally a
// no-op: it only fails to match text containing a literal backslash,
// which excludes nothing in practice, so every value - including dates
// that literally contain "31.12" - passes.
it('as authored (double backslash): the 31.12-exclusion lookahead is a no-op and matches everything', () => {
const regex = parseRegexRule('/^(?:(?!31\\\\.12).)*$/')
expect(regex.test('31.12.2024')).toBeTrue()
expect(regex.test('01.01.2024')).toBeTrue()
})
})
})
@@ -1,46 +0,0 @@
/**
* HARDREGEX/SOFTREGEX rule values are authored as SAS PRX patterns, which
* require the delimiter form `/pattern/flags` (prxparse's own syntax check
* in mpe_validations_postedit.sas requires it). A bare pattern with no
* delimiters is also accepted, for backwards compatibility with existing
* rules/tests. Passing the delimited form as-is to `new RegExp(string)`
* would treat the slashes and flags as literal pattern text instead of
* stripping/applying them, so this parses the PRX form before constructing
* the regex.
*
* PRX also accepts some Perl-only regex syntax with no direct JS equivalent.
* Three mechanical, unambiguous cases are translated below - a leading
* `(?i)` modifier, an escaped `\Q...\E` literal sequence, and `\A`/`\z`
* absolute anchors.
* Atomic groups (`(?>...)`) and possessive quantifiers (`a++`) are NOT
* translated: both would need a lookahead+backreference rewrite that
* changes the pattern's capture-group numbering, a real correctness risk
* for comparatively rare constructs, so they're left to fail (they already
* throw, which every caller already treats as "always valid" / "never
* warn", same as any other malformed pattern).
*/
const escapeRegExpMetacharacters = (text: string): string =>
text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
export const parseRegexRule = (ruleValue: string): RegExp => {
const delimited = /^\/(.*)\/([a-z]*)$/s.exec(ruleValue)
if (!delimited) return new RegExp(ruleValue)
let [, body, flags] = delimited
const inlineCaseInsensitive = /^\(\?i\)/.exec(body)
if (inlineCaseInsensitive) {
body = body.slice(inlineCaseInsensitive[0].length)
if (!flags.includes('i')) flags += 'i'
}
body = body.replace(/\\Q([\s\S]*?)\\E/g, (_match, literal) =>
escapeRegExpMetacharacters(literal)
)
body = body.replace(/\\A/g, '^').replace(/\\z/g, '(?![\\s\\S])')
return new RegExp(body, flags)
}

Some files were not shown because too many files have changed in this diff Show More