Compare commits
64
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dcae9060f | ||
|
|
f4525cc62a | ||
|
|
d4aa70ddbf | ||
|
|
4c2aca3495 | ||
|
|
36c9ef0e95 | ||
|
|
7511b64523 | ||
|
|
7b4b4ebeeb | ||
|
|
c7ba025d39 | ||
|
|
b5e9b25319 | ||
|
|
82a254d22c | ||
|
|
0d7fd34297 | ||
|
|
1beb3d490d | ||
|
|
1fb5e8eb37 | ||
|
|
78dd29ba78 | ||
|
|
ba7b61082d | ||
|
|
1a9231809e | ||
|
|
df5c975869 | ||
|
|
2728dac873 | ||
|
|
88f55b9d06 | ||
|
|
301675052f | ||
|
|
45c434271d | ||
|
|
f523c798c3 | ||
|
|
41fd618884 | ||
|
|
f368242cb0 | ||
|
|
636e4e5b4a | ||
|
|
49710eb62d | ||
|
|
25c12f2b18 | ||
|
|
3ba8858260 | ||
|
|
81014001ac | ||
|
|
691d6f277e | ||
|
|
e7453a9305 | ||
|
|
005b616adb | ||
|
|
0ae1781d28 | ||
|
|
fb94840016 | ||
|
|
6864c044dc | ||
|
|
b30ba3e76b | ||
|
|
a4583f2db0 | ||
|
|
a161670b86 | ||
|
|
e6cf5a8d25 | ||
|
|
7ebc26fc7f | ||
|
|
c9dd933cec | ||
|
|
716eba621f | ||
|
|
982d507ae3 | ||
|
|
27a12951e3 | ||
|
|
4c8e4ee1b2 | ||
|
|
a15fdc401e | ||
|
|
7019e39318 | ||
|
|
9ca57193b4 | ||
|
|
175eb9853a | ||
|
|
e859d3354e | ||
|
|
adc6424211 | ||
|
|
74442096ba | ||
|
|
9f84a580fb | ||
|
|
2f5d3dfc38 | ||
|
|
9b18a45d35 | ||
|
|
6a30de37ca | ||
|
|
31075a165f | ||
|
|
53b7ee0cb2 | ||
|
|
ebe6972dd7 | ||
|
|
2acea64589 | ||
|
|
ffa3ff9c10 | ||
|
|
0ac3ff4511 | ||
|
|
3429a7a2a6 | ||
|
|
56fb2c2a3a |
@@ -0,0 +1,606 @@
|
|||||||
|
---
|
||||||
|
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 (v17–19)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install handsontable @handsontable/angular-wrapper
|
||||||
|
```
|
||||||
|
|
||||||
|
The Angular wrapper was modernized in Handsontable v17.1 to align with Angular 17–19, 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 10k–100k+ 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 17–19; 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/
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# @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).
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# 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();
|
||||||
|
```
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# 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
|
||||||
|
});
|
||||||
|
```
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
# 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');
|
||||||
|
```
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# 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` |
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# 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'
|
||||||
|
```
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# 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.
|
||||||
|
```
|
||||||
@@ -146,7 +146,7 @@ jobs:
|
|||||||
# Start frontend and run cypress
|
# Start frontend and run cypress
|
||||||
# timeout 1800: SIGTERM after 30 min so Cypress can flush video/screenshots
|
# 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)
|
# before the outer timeout-minutes hard-kills the step (avoids silent multi-hour hangs)
|
||||||
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && timeout 1800 npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts"
|
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && timeout 1800 npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.cy.ts"
|
||||||
|
|
||||||
- name: Zip Cypress videos
|
- name: Zip Cypress videos
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ jobs:
|
|||||||
- name: Update appLoc in index.html
|
- name: Update appLoc in index.html
|
||||||
run: |
|
run: |
|
||||||
cd client
|
cd client
|
||||||
replace-in-files --regex='appLoc=".*?"' --replacement='appLoc="/Public/app/devtest"' ./src/index.html
|
replace-in-files --regex='appLoc=".*?"' --replacement='appLoc="/proj/sasjs/genesis-mocks"' ./src/index.html
|
||||||
|
|
||||||
- name: Build Frontend
|
- name: Build Frontend
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -47,9 +47,9 @@ jobs:
|
|||||||
- name: Check audit
|
- name: Check audit
|
||||||
# Audit should fail and stop the CI if critical vulnerability found
|
# Audit should fail and stop the CI if critical vulnerability found
|
||||||
run: |
|
run: |
|
||||||
npm audit --omit=dev
|
npm audit --audit-level=critical --omit=dev
|
||||||
cd ./sas
|
cd ./sas
|
||||||
npm audit --omit=dev
|
npm audit --audit-level=critical --omit=dev
|
||||||
cd ../client
|
cd ../client
|
||||||
npm audit --audit-level=critical --omit=dev
|
npm audit --audit-level=critical --omit=dev
|
||||||
|
|
||||||
@@ -143,7 +143,7 @@ jobs:
|
|||||||
replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts
|
replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts
|
||||||
cat ./cypress.config.ts
|
cat ./cypress.config.ts
|
||||||
# Start frontend and run cypress
|
# Start frontend and run cypress
|
||||||
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts"
|
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.cy.ts"
|
||||||
|
|
||||||
- name: Zip Cypress videos
|
- name: Zip Cypress videos
|
||||||
if: always()
|
if: always()
|
||||||
@@ -161,6 +161,16 @@ jobs:
|
|||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [Build-production-and-ng-test, Build-and-test-development]
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -193,12 +203,41 @@ jobs:
|
|||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
- name: Create Empty Release (assets are posted later)
|
- 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: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
npm i
|
npm i
|
||||||
npm i -g semantic-release
|
npm i -g 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
|
# We do a semantic-release DRY RUN to make the job fail if there are
|
||||||
GITEA_TOKEN=${{ secrets.RELEASE_TOKEN }} GITEA_URL=https://git.datacontroller.io semantic-release
|
# 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
|
||||||
|
|
||||||
- name: Frontend Build
|
- name: Frontend Build
|
||||||
description: Must be created AFTER the release as the version (git tag) is used in the interface
|
description: Must be created AFTER the release as the version (git tag) is used in the interface
|
||||||
@@ -272,17 +311,40 @@ jobs:
|
|||||||
surfer put --token ${{ secrets.CODE_DATACONTROLLER_IO }} --server code.datacontroller.io sasjsbuild/sasdocs/* /
|
surfer put --token ${{ secrets.CODE_DATACONTROLLER_IO }} --server code.datacontroller.io sasjsbuild/sasdocs/* /
|
||||||
|
|
||||||
- name: Upload assets to release
|
- 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: |
|
run: |
|
||||||
RELEASE_ID=`curl -k 'https://git.datacontroller.io/api/v1/repos/dc/dc/releases/latest?access_token=${{ secrets.RELEASE_TOKEN }}' | jq -r '.id'`
|
set -euo pipefail
|
||||||
RELEASE_BODY=`curl -k 'https://git.datacontroller.io/api/v1/repos/dc/dc/releases/latest?access_token=${{ secrets.RELEASE_TOKEN }}' | jq -r '.body'`
|
# Send the token via Authorization header rather than ?access_token=
|
||||||
# Update body
|
# (the query-string form is deprecated and leaks into access logs).
|
||||||
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 }}
|
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"
|
||||||
|
|
||||||
# Upload assets
|
# Upload assets
|
||||||
URL="https://git.datacontroller.io/api/v1/repos/dc/dc/releases/$RELEASE_ID/assets?access_token=${{ secrets.RELEASE_TOKEN }}"
|
URL="$BASE/releases/$RELEASE_ID/assets"
|
||||||
curl -k $URL -F attachment=@frontend.zip
|
for f in frontend.zip \
|
||||||
curl -k $URL -F attachment=@sas/demostream_sas9.sas
|
sas/demostream_sas9.sas \
|
||||||
curl -k $URL -F attachment=@sas/viya.sas
|
sas/viya.sas \
|
||||||
curl -k $URL -F attachment=@sas/sasjs_server.json.zip
|
sas/sasjs_server.json.zip \
|
||||||
curl -k $URL -F attachment=@sas/sas9.sas
|
sas/sas9.sas \
|
||||||
curl -k $URL -F attachment=@sas/viya_noweb.sas
|
sas/viya_noweb.sas \
|
||||||
curl -k $URL -F attachment=@sas/viya_noweb.json
|
sas/viya_noweb.json; do
|
||||||
|
echo "Uploading $f ..."
|
||||||
|
curl -k --fail-with-body -sS -H "$AUTH_HEADER" "$URL" -F "attachment=@$f"
|
||||||
|
done
|
||||||
|
|||||||
Vendored
+1
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"cSpell.words": [
|
"cSpell.words": [
|
||||||
|
"Handsontable",
|
||||||
"Licence",
|
"Licence",
|
||||||
"SYSERRORTEXT",
|
"SYSERRORTEXT",
|
||||||
"SYSWARNINGTEXT",
|
"SYSWARNINGTEXT",
|
||||||
|
|||||||
@@ -1,3 +1,94 @@
|
|||||||
|
## [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)
|
## [7.8.2](https://git.datacontroller.io/dc/dc/compare/v7.8.1...v7.8.2) (2026-05-20)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# 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
|
||||||
+8
-1
@@ -68,7 +68,14 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": ["src/styles.scss"],
|
"styles": ["src/styles.scss"],
|
||||||
"scripts": ["node_modules/marked/marked.min.js"],
|
"scripts": [
|
||||||
|
"node_modules/marked/marked.min.js",
|
||||||
|
{
|
||||||
|
"input": "src/assets/va-early.js",
|
||||||
|
"bundleName": "va-early",
|
||||||
|
"inject": true
|
||||||
|
}
|
||||||
|
],
|
||||||
"webWorkerTsConfig": "tsconfig.worker.json",
|
"webWorkerTsConfig": "tsconfig.worker.json",
|
||||||
"main": "src/main.ts"
|
"main": "src/main.ts"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// Marks this file as an ES module (rather than a global script) so its
|
||||||
|
// top-level consts don't collide, under the TS type-checker, with the same
|
||||||
|
// names declared in other spec files — see viewer-labels.cy.ts for the same
|
||||||
|
// pattern.
|
||||||
|
export {}
|
||||||
|
|
||||||
|
const username = Cypress.env('username')
|
||||||
|
const password = Cypress.env('password')
|
||||||
|
const hostUrl = Cypress.env('hosturl')
|
||||||
|
const appLocation = Cypress.env('appLocation')
|
||||||
|
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
|
||||||
|
const serverType = Cypress.env('serverType')
|
||||||
|
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
|
||||||
|
|
||||||
|
// Regression coverage for issue #253's vertical-array COLTYPE fix — see
|
||||||
|
// getdata.sas now sends one COLTYPE JSON-object string per cols[] row (via
|
||||||
|
// a LEFT JOIN keyed off the real dataset's columns), instead of one
|
||||||
|
// comma-joined sasparams.COLTYPE string covering every column. Two things
|
||||||
|
// are specifically at risk from that change and aren't covered elsewhere:
|
||||||
|
// 1. `_____DELETE__THIS__RECORD_____` (the delete checkbox column) is a
|
||||||
|
// client-side-only concept — %mp_getcols never has a row for it, so
|
||||||
|
// it can no longer travel via cols[].COLTYPE at all. Its Yes/No
|
||||||
|
// dropdown rule is now hardcoded client-side (deleteRecordColumnRule.ts)
|
||||||
|
// — test 1 proves that hardcode actually renders end to end.
|
||||||
|
// 2. Ordinary columns (e.g. SOME_DROPDOWN) still get their rule via the
|
||||||
|
// new per-row cols[].COLTYPE path — test 2 proves the migration didn't
|
||||||
|
// silently drop real columns' rules either.
|
||||||
|
// Column positions below come from COLHEADERS in
|
||||||
|
// sas/mocks/sasjs/services/editors/getdata.js:
|
||||||
|
// "_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,SOME_CHAR,SOME_DROPDOWN,..."
|
||||||
|
// — childNodes[0] on a body <tr> is the row-header <th>, so childNodes[N+1]
|
||||||
|
// is the Nth (0-indexed) column in that list.
|
||||||
|
context('coltype vertical-array regression tests: ', function () {
|
||||||
|
this.beforeAll(() => {
|
||||||
|
cy.visit(`${hostUrl}/SASLogon/logout`)
|
||||||
|
cy.loginAndUpdateValidKey(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.beforeEach(() => {
|
||||||
|
cy.visit(hostUrl + appLocation)
|
||||||
|
visitPage('home')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('1 | delete-record column renders its hardcoded Yes/No dropdown', () => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||||
|
clickOnEdit()
|
||||||
|
|
||||||
|
cy.get('.ht_master tbody tr', { timeout: longerCommandTimeout }).then(
|
||||||
|
(rows: any) => {
|
||||||
|
// childNodes[1] = _____DELETE__THIS__RECORD_____ (1st column)
|
||||||
|
cy.get(rows[1].childNodes[1])
|
||||||
|
.click({ force: true })
|
||||||
|
.then(($td) => {
|
||||||
|
cy.get('.htAutocompleteArrow', { withinSubject: $td })
|
||||||
|
.should('exist')
|
||||||
|
.click({ force: true })
|
||||||
|
|
||||||
|
cy.get('.autocompleteEditor .htCore tbody td').should(
|
||||||
|
($choices) => {
|
||||||
|
const texts = [...$choices].map((el) => el.innerText.trim())
|
||||||
|
expect(texts).to.deep.equal(['No', 'Yes'])
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('2 | an ordinary dropdown column (SOME_DROPDOWN) still gets its rule from cols[].COLTYPE', () => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||||
|
clickOnEdit()
|
||||||
|
|
||||||
|
cy.get('.ht_master tbody tr', { timeout: longerCommandTimeout }).then(
|
||||||
|
(rows: any) => {
|
||||||
|
// childNodes[4] = SOME_DROPDOWN (4th column, 0-indexed as 3). This
|
||||||
|
// only holds if DcValidator emits rules in VARNUM order — see the
|
||||||
|
// "orders rules by VARNUM" regression test in dc-validator.spec.ts.
|
||||||
|
cy.get(rows[1].childNodes[4])
|
||||||
|
.click({ force: true })
|
||||||
|
.then(($td) => {
|
||||||
|
cy.get('.htAutocompleteArrow', { withinSubject: $td }).should(
|
||||||
|
'exist'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const clickOnEdit = (callback?: any) => {
|
||||||
|
cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout })
|
||||||
|
.click()
|
||||||
|
.then(() => {
|
||||||
|
if (callback) callback()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
|
||||||
|
cy.get('.app-loading', { timeout: longerCommandTimeout })
|
||||||
|
.should('not.exist')
|
||||||
|
.then(() => {
|
||||||
|
cy.get('.nav-tree clr-tree > clr-tree-node', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).then((treeNodes: any) => {
|
||||||
|
let viyaLib
|
||||||
|
|
||||||
|
for (let node of treeNodes) {
|
||||||
|
if (node.innerText.toLowerCase().includes(libNameIncludes)) {
|
||||||
|
viyaLib = node
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cy.get(viyaLib).within(() => {
|
||||||
|
cy.get('.clr-tree-node-content-container > button').click()
|
||||||
|
|
||||||
|
cy.get('.clr-treenode-link').then((innerNodes: any) => {
|
||||||
|
for (let innerNode of innerNodes) {
|
||||||
|
if (innerNode.innerText.toLowerCase().includes(tablename)) {
|
||||||
|
innerNode.click()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const visitPage = (url: string) => {
|
||||||
|
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
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}`)
|
||||||
|
}
|
||||||
@@ -386,6 +386,19 @@ context('excel tests: ', function () {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('24 | Uploads Excel with leading whitespace in header row (should still succeed)', (done) => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||||
|
|
||||||
|
// Every header except the first has a leading space, matching a
|
||||||
|
// real-world defect from copy-pasting between spreadsheets/systems.
|
||||||
|
// Header matching must tolerate
|
||||||
|
// this rather than reporting the columns missing and aborting.
|
||||||
|
attachExcelFile('leading_whitespace_header_excel.xlsx', () => {
|
||||||
|
submitExcel()
|
||||||
|
rejectExcel(done)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// Large files break Cypress
|
// Large files break Cypress
|
||||||
|
|
||||||
// it ('? | Uploads Excel with size of 5MB', (done) => {
|
// it ('? | Uploads Excel with size of 5MB', (done) => {
|
||||||
@@ -559,13 +572,13 @@ const checkResultOfXLSUpload = (callback?: any) => {
|
|||||||
expect(cell.innerText).to.equal('▼\nOption 1')
|
expect(cell.innerText).to.equal('▼\nOption 1')
|
||||||
cell = data[0].children[0].children[5]
|
cell = data[0].children[0].children[5]
|
||||||
expect(cell.innerText).to.equal('42')
|
expect(cell.innerText).to.equal('42')
|
||||||
// Cells render locale-formatted (Cypress pins --lang=en-GB); stored value
|
// Cells render the raw stored ISO string verbatim (locale-independent;
|
||||||
// stays raw ISO. date -> dd/mm/yyyy, datetime -> dd/mm/yyyy, HH:mm:ss,
|
// see registerIntlCellTypes). date -> YYYY-MM-DD, datetime ->
|
||||||
// time -> HH:mm:ss (en-GB 24h, identical to the raw ISO time).
|
// YYYY-MM-DD HH:mm:ss, time -> HH:mm:ss.
|
||||||
cell = data[0].children[0].children[6]
|
cell = data[0].children[0].children[6]
|
||||||
expect(cell.innerText).to.equal('12/02/1960')
|
expect(cell.innerText).to.equal('1960-02-12')
|
||||||
cell = data[0].children[0].children[7]
|
cell = data[0].children[0].children[7]
|
||||||
expect(cell.innerText).to.equal('01/01/1960, 00:00:42')
|
expect(cell.innerText).to.equal('1960-01-01 00:00:42')
|
||||||
cell = data[0].children[0].children[8]
|
cell = data[0].children[0].children[8]
|
||||||
expect(cell.innerText).to.equal('00:00:42')
|
expect(cell.innerText).to.equal('00:00:42')
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
// Marks this file as an ES module (rather than a global script) so its
|
||||||
|
// top-level consts don't collide, under the TS type-checker, with the same
|
||||||
|
// names declared in other spec files — see e.g. download.cy.ts, which gets
|
||||||
|
// this for free via a real import.
|
||||||
|
export {}
|
||||||
|
|
||||||
|
const username = Cypress.env('username')
|
||||||
|
const password = Cypress.env('password')
|
||||||
|
const hostUrl = Cypress.env('hosturl')
|
||||||
|
const appLocation = Cypress.env('appLocation')
|
||||||
|
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
|
||||||
|
const serverType = Cypress.env('serverType')
|
||||||
|
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
|
||||||
|
|
||||||
|
// Viewer column-label display toggle (`?labels=true`): clients surfacing DC in
|
||||||
|
// SAS Visual Analytics want column LABELs shown instead of NAMEs. Default
|
||||||
|
// behavior (no param) must stay unchanged; the right-click context menu's
|
||||||
|
// "Show labels"/"Show names" item is the in-app affordance, and it drives the
|
||||||
|
// same URL param so state is shareable/refreshable. See mock data in
|
||||||
|
// sas/mocks/sasjs/services/public/viewdata.js: MPE_X_TEST's SOME_CHAR/SOME_DATE
|
||||||
|
// have LABELs that differ from NAME, and SOME_NUM has a blank LABEL (fallback
|
||||||
|
// to NAME) — this spec exercises exactly that fixture.
|
||||||
|
context('viewer column labels toggle tests: ', function () {
|
||||||
|
this.beforeAll(() => {
|
||||||
|
cy.visit(`${hostUrl}/SASLogon/logout`)
|
||||||
|
// forceLicenceKey=true: without it, loginAndUpdateValidKey() only
|
||||||
|
// generates key data and logs back out if no valid license already
|
||||||
|
// exists — it doesn't actually apply one (see commands.ts). Several
|
||||||
|
// specs call it with no argument and rely on an *earlier* spec in the
|
||||||
|
// same CI run having already forced a valid license onto the shared
|
||||||
|
// server first (csv.cy.ts, excel.cy.ts, etc.). This spec shouldn't
|
||||||
|
// depend on run order, so force it explicitly.
|
||||||
|
cy.loginAndUpdateValidKey(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.beforeEach(() => {
|
||||||
|
// No re-login here: beforeAll's loginAndUpdateValidKey() already
|
||||||
|
// authenticated the session, and it persists across tests in this file
|
||||||
|
// (see filtering.cy.ts/csv.cy.ts for the same pattern). Re-typing into
|
||||||
|
// the login form would fail — it's hidden once already logged in.
|
||||||
|
cy.visit(hostUrl + appLocation)
|
||||||
|
|
||||||
|
// Visit 'home' first, matching filtering.cy.ts/csv.cy.ts/download.cy.ts —
|
||||||
|
// going straight to 'view/data' skips whatever startup-data fetch 'home'
|
||||||
|
// triggers, which passed locally (fast round-trips) but left the
|
||||||
|
// nav-tree empty in CI's slower environment.
|
||||||
|
visitPage('home')
|
||||||
|
visitPage('view/data')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('1 | default (no param): headers show NAMEs', () => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||||
|
|
||||||
|
cy.get('#hotTable .ht_clone_top .htCore thead tr', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).should(($headerRow) => {
|
||||||
|
expect($headerRow[0].innerHTML).to.include('SOME_CHAR')
|
||||||
|
expect($headerRow[0].innerHTML).to.not.include('Some Character Column')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('2 | ?labels=true: headers show LABELs, blank LABEL falls back to NAME', () => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||||
|
appendUrlParam('labels=true')
|
||||||
|
|
||||||
|
cy.get('#hotTable .ht_clone_top .htCore thead tr', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).should(($headerRow) => {
|
||||||
|
const html = $headerRow[0].innerHTML
|
||||||
|
expect(html).to.include('Some Character Column')
|
||||||
|
expect(html).to.include('Some Date')
|
||||||
|
// SOME_NUM has a blank LABEL in the mock -> falls back to NAME
|
||||||
|
expect(html).to.include('SOME_NUM')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('3 | context menu "Show labels"/"Show names" toggles headers and the URL', () => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||||
|
|
||||||
|
toggleColumnLabelsFromContextMenu('Show labels')
|
||||||
|
|
||||||
|
cy.url().should('include', 'labels=true')
|
||||||
|
cy.get('#hotTable .ht_clone_top .htCore thead tr', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).should(($headerRow) => {
|
||||||
|
expect($headerRow[0].innerHTML).to.include('Some Character Column')
|
||||||
|
})
|
||||||
|
|
||||||
|
toggleColumnLabelsFromContextMenu('Show names')
|
||||||
|
|
||||||
|
cy.url().should('not.include', 'labels=true')
|
||||||
|
cy.get('#hotTable .ht_clone_top .htCore thead tr', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).should(($headerRow) => {
|
||||||
|
expect($headerRow[0].innerHTML).to.include('SOME_CHAR')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('4 | ?embed=va&labels=true: labels shown with embed chrome hidden', () => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||||
|
appendUrlParam('embed=va&labels=true')
|
||||||
|
|
||||||
|
cy.get('header.app-header').should('not.exist')
|
||||||
|
cy.get('#hotTable .ht_clone_top .htCore thead tr', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).should(($headerRow) => {
|
||||||
|
expect($headerRow[0].innerHTML).to.include('Some Character Column')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('5 | info dropdown: first line is NAME (in both toggle states)', () => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||||
|
|
||||||
|
// No assertion on the item's label text here: `info` has a custom
|
||||||
|
// `renderer`, so Handsontable never renders its `name` ("test info") as
|
||||||
|
// text at all — the renderer's own output (checked below) is the only
|
||||||
|
// real content.
|
||||||
|
openColumnDropdown('SOME_CHAR')
|
||||||
|
cy.get('.htDropdownMenu').should(($menu) => {
|
||||||
|
expect($menu.text()).to.match(/NAME: SOME_CHAR/)
|
||||||
|
})
|
||||||
|
cy.get('body').click(0, 0) // close menu
|
||||||
|
|
||||||
|
appendUrlParam('labels=true')
|
||||||
|
|
||||||
|
openColumnDropdown('Some Character Column')
|
||||||
|
cy.get('.htDropdownMenu').should(($menu) => {
|
||||||
|
// NAME is always shown first, even while headers display as LABEL
|
||||||
|
expect($menu.text()).to.match(/NAME: SOME_CHAR/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const visitPage = (url: string) => {
|
||||||
|
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-visits the current route with extra hash-query params appended, then
|
||||||
|
// forces a real reload. Under hash routing (`useHash: true`), changing only
|
||||||
|
// the URL's hash fragment is a same-document navigation — Angular Router
|
||||||
|
// still reacts to it (which is enough for `useLabels`, read reactively from
|
||||||
|
// `route.queryParams`), but `embed` is parsed once from `window.location.hash`
|
||||||
|
// at app bootstrap (app.component.ts), so `?embed=va` only takes effect after
|
||||||
|
// an actual reload.
|
||||||
|
const appendUrlParam = (param: string) => {
|
||||||
|
cy.url().then((url) => {
|
||||||
|
const separator = url.includes('?') ? '&' : '?'
|
||||||
|
cy.visit(`${url}${separator}${param}`)
|
||||||
|
cy.reload()
|
||||||
|
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
|
||||||
|
'not.exist'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const openColumnDropdown = (headerText: string) => {
|
||||||
|
// Handsontable renders the sticky/frozen header via a separate clone pane
|
||||||
|
// (.ht_clone_top) for scroll behavior; the header row inside the main
|
||||||
|
// .ht_master pane is kept visibility:hidden (replaced visually by the
|
||||||
|
// clone). .ht_clone_top is the real, interactive one — confirmed via
|
||||||
|
// `document.querySelector('#hotTable .ht_clone_top thead').innerText` in
|
||||||
|
// a live browser session.
|
||||||
|
cy.get('#hotTable .ht_clone_top .htCore thead button.changeType', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
})
|
||||||
|
.parents('th')
|
||||||
|
.filter((_, th) => Cypress.$(th).text().includes(headerText))
|
||||||
|
.last()
|
||||||
|
.as('targetHeader')
|
||||||
|
|
||||||
|
// Click the header text first to select the column — the `info` item's
|
||||||
|
// renderer reads hot.getSelected() to decide which column to describe,
|
||||||
|
// so without an active selection it always shows "No info found".
|
||||||
|
cy.get('@targetHeader').click()
|
||||||
|
cy.get('@targetHeader').find('button.changeType').click({ force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleColumnLabelsFromContextMenu = (
|
||||||
|
menuItemText: 'Show labels' | 'Show names'
|
||||||
|
) => {
|
||||||
|
cy.get('#hotTable .ht_master.handsontable .htCore tbody tr td', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
})
|
||||||
|
.first()
|
||||||
|
// force: true — the first body row can sit under an overlay clone's
|
||||||
|
// header/sort-icon layer, which fails Cypress's actionability check
|
||||||
|
// even though the cell is the real rightclick target underneath.
|
||||||
|
.rightclick({ force: true })
|
||||||
|
|
||||||
|
cy.get('.htContextMenu').contains(menuItemText).click()
|
||||||
|
|
||||||
|
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
|
||||||
|
}
|
||||||
|
|
||||||
|
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
|
||||||
|
cy.get('.app-loading', { timeout: longerCommandTimeout })
|
||||||
|
.should('not.exist')
|
||||||
|
.then(() => {
|
||||||
|
cy.get('.nav-tree clr-tree > clr-tree-node', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).then((treeNodes: any) => {
|
||||||
|
let viyaLib
|
||||||
|
|
||||||
|
for (let node of treeNodes) {
|
||||||
|
if (node.innerText.toLowerCase().includes(libNameIncludes)) {
|
||||||
|
viyaLib = node
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cy.get(viyaLib).within(() => {
|
||||||
|
// Small settle wait: right after a fresh visit/reload, the tree
|
||||||
|
// component can still be re-rendering, causing this node to be
|
||||||
|
// found then swapped out mid-click ("disappeared from the page").
|
||||||
|
cy.wait(300)
|
||||||
|
|
||||||
|
cy.get(
|
||||||
|
'.clr-tree-node-content-container .clr-treenode-content p'
|
||||||
|
).click()
|
||||||
|
|
||||||
|
cy.get('.clr-treenode-link').then((innerNodes: any) => {
|
||||||
|
for (let innerNode of innerNodes) {
|
||||||
|
if (innerNode.innerText.toLowerCase().includes(tablename)) {
|
||||||
|
innerNode.click()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Selecting the table triggers async SPA routing + a viewdata fetch;
|
||||||
|
// wait for the grid to actually render before any subsequent action
|
||||||
|
// (reading the URL, right-clicking, opening a header dropdown) — without
|
||||||
|
// this, callers can act while still on the intermediate library-only
|
||||||
|
// route or a not-yet-rendered grid. Waiting specifically for a header's
|
||||||
|
// dropdown button (not just the <tr> shell) confirms Handsontable has
|
||||||
|
// finished populating header cell contents, not just their DOM rows.
|
||||||
|
cy.get('#hotTable .ht_clone_top .htCore thead button.changeType', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).should('exist')
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -23,21 +23,33 @@
|
|||||||
//
|
//
|
||||||
// -- This will overwrite an existing command --
|
// -- This will overwrite an existing command --
|
||||||
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
|
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
|
||||||
import 'cypress-file-upload';
|
import 'cypress-file-upload'
|
||||||
|
|
||||||
import { arrayBufferToBase64 } from './../util/helper-functions'
|
import { arrayBufferToBase64 } from './../util/helper-functions'
|
||||||
import * as moment from 'moment'
|
import moment from 'moment'
|
||||||
|
|
||||||
const username = Cypress.env('username');
|
// These custom commands were added via Cypress.Commands.add() below but never
|
||||||
const password = Cypress.env('password');
|
// had a type augmentation, so every spec calling cy.loginAndUpdateValidKey()/
|
||||||
const hostUrl = Cypress.env('hosturl');
|
// cy.isLoggedIn() had an unresolved-property error.
|
||||||
const appLocation = Cypress.env('appLocation');
|
declare global {
|
||||||
|
namespace Cypress {
|
||||||
|
interface Chainable {
|
||||||
|
isLoggedIn(callback: (exist: boolean) => void): Chainable<void>
|
||||||
|
loginAndUpdateValidKey(forceLicenceKey?: boolean): Chainable<void>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = Cypress.env('username')
|
||||||
|
const password = Cypress.env('password')
|
||||||
|
const hostUrl = Cypress.env('hosturl')
|
||||||
|
const appLocation = Cypress.env('appLocation')
|
||||||
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
|
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
|
||||||
const site_id_SASJS = Cypress.env('site_id_SASJS')
|
const site_id_SASJS = Cypress.env('site_id_SASJS')
|
||||||
|
|
||||||
Cypress.Commands.add('isLoggedIn', (callback: (exist: boolean) => void) => {
|
Cypress.Commands.add('isLoggedIn', (callback: (exist: boolean) => void) => {
|
||||||
cy.get('body').then($body => {
|
cy.get('body').then(($body) => {
|
||||||
if ($body.find(".nav-tree").length > 0) {
|
if ($body.find('.nav-tree').length > 0) {
|
||||||
if (callback) callback(true)
|
if (callback) callback(true)
|
||||||
} else {
|
} else {
|
||||||
if (callback) callback(false)
|
if (callback) callback(false)
|
||||||
@@ -45,76 +57,92 @@ Cypress.Commands.add('isLoggedIn', (callback: (exist: boolean) => void) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Cypress.Commands.add('loginAndUpdateValidKey', (forceLicenceKey: boolean = false) => {
|
Cypress.Commands.add(
|
||||||
cy.visit(hostUrl + appLocation);
|
'loginAndUpdateValidKey',
|
||||||
|
(forceLicenceKey: boolean = false) => {
|
||||||
|
cy.visit(hostUrl + appLocation)
|
||||||
|
|
||||||
cy.wait(2000)
|
cy.wait(2000)
|
||||||
|
|
||||||
cy.get('body').then($body =>{
|
cy.get('body').then(($body) => {
|
||||||
const usernameInput = $body.find("input.username")[0]
|
const usernameInput = $body.find('input.username')[0]
|
||||||
|
|
||||||
if (usernameInput && !Cypress.dom.isHidden(usernameInput)) {
|
if (usernameInput && !Cypress.dom.isHidden(usernameInput)) {
|
||||||
cy.get('input.username').type(username);
|
cy.get('input.username').type(username)
|
||||||
cy.get('input.password').type(password);
|
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.get('.app-loading', { timeout: longerCommandTimeout })
|
||||||
cy.wait(2000)
|
.should('not.exist')
|
||||||
|
.then(() => {
|
||||||
|
cy.wait(2000)
|
||||||
|
|
||||||
if ($body.find(".nav-tree").length > 0) {
|
if ($body.find('.nav-tree').length > 0) {
|
||||||
/**
|
/**
|
||||||
* If licence key is already working, then skip rest of the function
|
* If licence key is already working, then skip rest of the function
|
||||||
*/
|
*/
|
||||||
return logout(() => {
|
return logout(() => {
|
||||||
return
|
return
|
||||||
})
|
})
|
||||||
} else{
|
} else {
|
||||||
const keyData = {
|
const keyData = {
|
||||||
valid_until: moment().add(20, 'day').format('YYYY-MM-DD'),
|
valid_until: moment().add(20, 'day').format('YYYY-MM-DD'),
|
||||||
number_of_users: 10,
|
number_of_users: 10,
|
||||||
hot_license_key: '',
|
hot_license_key: '',
|
||||||
site_id: '',
|
site_id: '',
|
||||||
demo: false
|
demo: false
|
||||||
}
|
|
||||||
|
|
||||||
return generateKeys(keyData.valid_until, keyData.number_of_users, keyData.hot_license_key, keyData.demo, (keysGen: any) => {
|
|
||||||
return acceptTermsIfPresented((result: boolean) => {
|
|
||||||
if (result) {
|
|
||||||
cy.wait(20000)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!forceLicenceKey) {
|
return generateKeys(
|
||||||
return logout(() => {
|
keyData.valid_until,
|
||||||
return
|
keyData.number_of_users,
|
||||||
})
|
keyData.hot_license_key,
|
||||||
} else {
|
keyData.demo,
|
||||||
return updateLicenseKeyQuick(keysGen, () => {
|
(keysGen: any) => {
|
||||||
cy.wait(25000)
|
|
||||||
return acceptTermsIfPresented((result: boolean) => {
|
return acceptTermsIfPresented((result: boolean) => {
|
||||||
if (result) {
|
if (result) {
|
||||||
cy.wait(20000)
|
cy.wait(20000)
|
||||||
}
|
}
|
||||||
return logout(() => {
|
|
||||||
return
|
if (!forceLicenceKey) {
|
||||||
})
|
return logout(() => {
|
||||||
|
return
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
return updateLicenseKeyQuick(keysGen, () => {
|
||||||
|
cy.wait(25000)
|
||||||
|
return acceptTermsIfPresented((result: boolean) => {
|
||||||
|
if (result) {
|
||||||
|
cy.wait(20000)
|
||||||
|
}
|
||||||
|
return logout(() => {
|
||||||
|
return
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
}
|
||||||
}
|
)
|
||||||
})
|
}
|
||||||
})
|
})
|
||||||
}
|
|
||||||
})
|
})
|
||||||
});
|
}
|
||||||
});
|
)
|
||||||
|
|
||||||
const logout = (callback?: any) => {
|
const logout = (callback?: any) => {
|
||||||
cy.get('.header-actions .dropdown-toggle').click().then(() => {
|
cy.get('.header-actions .dropdown-toggle')
|
||||||
cy.get('.header-actions .dropdown-menu > .separator').next().click().then(() => {
|
.click()
|
||||||
if (callback) callback()
|
.then(() => {
|
||||||
|
cy.get('.header-actions .dropdown-menu > .separator')
|
||||||
|
.next()
|
||||||
|
.click()
|
||||||
|
.then(() => {
|
||||||
|
if (callback) callback()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateLicenseKeyQuick = (keys: any, callback: any) => {
|
const updateLicenseKeyQuick = (keys: any, callback: any) => {
|
||||||
@@ -132,11 +160,15 @@ const updateLicenseKeyQuick = (keys: any, callback: any) => {
|
|||||||
const acceptTermsIfPresented = (callback?: any) => {
|
const acceptTermsIfPresented = (callback?: any) => {
|
||||||
cy.url().then((url: string) => {
|
cy.url().then((url: string) => {
|
||||||
if (url.includes('licensing/register')) {
|
if (url.includes('licensing/register')) {
|
||||||
cy.get('.card-block').scrollTo('bottom').then(() => {
|
cy.get('.card-block')
|
||||||
cy.get('#checkbox1').click().then(() => {
|
.scrollTo('bottom')
|
||||||
if (callback) callback(true)
|
.then(() => {
|
||||||
|
cy.get('#checkbox1')
|
||||||
|
.click()
|
||||||
|
.then(() => {
|
||||||
|
if (callback) callback(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
if (callback) callback(false)
|
if (callback) callback(false)
|
||||||
}
|
}
|
||||||
@@ -151,23 +183,36 @@ const isLicensingPage = (callback: any) => {
|
|||||||
|
|
||||||
const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => {
|
const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => {
|
||||||
cy.get('button').contains('Paste licence').click()
|
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('.license-key-form textarea', { timeout: longerCommandTimeout })
|
||||||
cy.get('.activation-key-form textarea', {timeout: longerCommandTimeout}).invoke('val', activationKey).trigger('input').should('not.be.undefined')
|
.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()
|
cy.get('button.apply-keys').click()
|
||||||
}
|
}
|
||||||
|
|
||||||
const visitPage = (url: string) => {
|
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) => {
|
const generateKeys = async (
|
||||||
let keyPair = await window.crypto.subtle.generateKey({
|
valid_until: string,
|
||||||
name: "RSA-OAEP",
|
users_allowed: number,
|
||||||
modulusLength: 2024,
|
hot_license_key: string,
|
||||||
publicExponent: new Uint8Array([1, 0, 1]),
|
demo: boolean,
|
||||||
hash: "SHA-256"
|
resultCallback?: any
|
||||||
|
) => {
|
||||||
|
let keyPair = await window.crypto.subtle.generateKey(
|
||||||
|
{
|
||||||
|
name: 'RSA-OAEP',
|
||||||
|
modulusLength: 2024,
|
||||||
|
publicExponent: new Uint8Array([1, 0, 1]),
|
||||||
|
hash: 'SHA-256'
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
["encrypt", "decrypt"]
|
['encrypt', 'decrypt']
|
||||||
)
|
)
|
||||||
|
|
||||||
let licenseData = {
|
let licenseData = {
|
||||||
@@ -184,30 +229,39 @@ const generateKeys = async (valid_until: string, users_allowed: number, hot_lice
|
|||||||
|
|
||||||
console.log(encoded)
|
console.log(encoded)
|
||||||
|
|
||||||
let cipher = await window.crypto.subtle.encrypt(
|
let cipher = await window.crypto.subtle
|
||||||
{
|
.encrypt(
|
||||||
name: "RSA-OAEP"
|
{
|
||||||
},
|
name: 'RSA-OAEP'
|
||||||
keyPair.publicKey,
|
},
|
||||||
encoded
|
keyPair.publicKey,
|
||||||
).then((value) => {
|
encoded
|
||||||
return value
|
)
|
||||||
}, (err) => {
|
.then(
|
||||||
console.log('Encrpyt error', err)
|
(value) => {
|
||||||
})
|
return value
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
console.log('Encrpyt error', err)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
if (!cipher) {
|
if (!cipher) {
|
||||||
alert('Encryptin keys failed')
|
alert('Encryptin keys failed')
|
||||||
throw new Error('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 activationKey = await arrayBufferToBase64(privateKeyBytes)
|
||||||
let licenseKey = await arrayBufferToBase64(cipher)
|
let licenseKey = await arrayBufferToBase64(cipher)
|
||||||
|
|
||||||
if (resultCallback) resultCallback({
|
if (resultCallback)
|
||||||
activationKey,
|
resultCallback({
|
||||||
licenseKey
|
activationKey,
|
||||||
})
|
licenseKey
|
||||||
}
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const check = (cwd) => {
|
|||||||
onlyAllow:
|
onlyAllow:
|
||||||
'AFLv2.1;Apache 2.0;Apache-2.0;Apache*;Artistic-2.0;0BSD;BSD*;BSD-2-Clause;BSD-3-Clause;CC0-1.0;CC-BY-3.0;CC-BY-4.0;ISC;MIT;MPL-2.0;ODC-By-1.0;Python-2.0;Unlicense;',
|
'AFLv2.1;Apache 2.0;Apache-2.0;Apache*;Artistic-2.0;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:
|
excludePackages:
|
||||||
'@cds/city@1.1.0;@handsontable/angular-wrapper@16.0.1;@handsontable/angular-wrapper@17.1.0;handsontable@^16.0.1;handsontable@16.2.0;handsontable@17.1.0;hyperformula@2.7.1;hyperformula@3.0.0;hyperformula@3.1.0;hyperformula@3.2.0;hyperformula@3.3.0;jackspeak@3.4.3;path-scurry@1.11.1;package-json-from-dist@1.0.1;buffers@0.1.1'
|
'@cds/city@1.1.0;@handsontable/angular-wrapper@16.0.1;@handsontable/angular-wrapper@17.1.0;@handsontable/angular-wrapper@18.0.0;handsontable@^16.0.1;handsontable@16.2.0;handsontable@17.1.0;handsontable@18.0.0;hyperformula@2.7.1;hyperformula@3.0.0;hyperformula@3.1.0;hyperformula@3.2.0;hyperformula@3.3.0;jackspeak@3.4.3;path-scurry@1.11.1;package-json-from-dist@1.0.1;buffers@0.1.1'
|
||||||
},
|
},
|
||||||
(error, json) => {
|
(error, json) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
Generated
+306
-197
@@ -20,7 +20,7 @@
|
|||||||
"@clr/angular": "file:libraries/clr-angular-17.9.0.tgz",
|
"@clr/angular": "file:libraries/clr-angular-17.9.0.tgz",
|
||||||
"@clr/icons": "^13.0.2",
|
"@clr/icons": "^13.0.2",
|
||||||
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
|
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
|
||||||
"@handsontable/angular-wrapper": "^17.1.0",
|
"@handsontable/angular-wrapper": "^18.0.0",
|
||||||
"@sasjs/adapter": "^4.17.0",
|
"@sasjs/adapter": "^4.17.0",
|
||||||
"@sasjs/utils": "^3.5.3",
|
"@sasjs/utils": "^3.5.3",
|
||||||
"@sheet/crypto": "file:libraries/sheet-crypto.tgz",
|
"@sheet/crypto": "file:libraries/sheet-crypto.tgz",
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
"d3-graphviz": "^5.0.2",
|
"d3-graphviz": "^5.0.2",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"fs-extra": "^7.0.1",
|
"fs-extra": "^7.0.1",
|
||||||
"handsontable": "^17.1.0",
|
"handsontable": "^18.0.0",
|
||||||
"https-browserify": "1.0.0",
|
"https-browserify": "1.0.0",
|
||||||
"hyperformula": "^2.5.0",
|
"hyperformula": "^2.5.0",
|
||||||
"iconv-lite": "^0.5.0",
|
"iconv-lite": "^0.5.0",
|
||||||
@@ -96,6 +96,7 @@
|
|||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"mochawesome": "^7.1.3",
|
"mochawesome": "^7.1.3",
|
||||||
"mutationobserver-shim": "^0.3.3",
|
"mutationobserver-shim": "^0.3.3",
|
||||||
|
"prettier": "3.8.4",
|
||||||
"replace-in-file": "^6.3.5",
|
"replace-in-file": "^6.3.5",
|
||||||
"rimraf": "3.0.2",
|
"rimraf": "3.0.2",
|
||||||
"ts-loader": "^9.2.8",
|
"ts-loader": "^9.2.8",
|
||||||
@@ -4360,25 +4361,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@handsontable/angular-wrapper": {
|
"node_modules/@handsontable/angular-wrapper": {
|
||||||
"version": "17.1.0",
|
"version": "18.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@handsontable/angular-wrapper/-/angular-wrapper-17.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@handsontable/angular-wrapper/-/angular-wrapper-18.0.0.tgz",
|
||||||
"integrity": "sha512-5jUCb4E1eZcy/CJ3T39/cxauy9NoQh+wNWTDW9ZhIIudr/00HvfJlcEozN5RWSYxfGNw71prVM2y16DSd+ct9A==",
|
"integrity": "sha512-1DTV9lqE7vn2sZbB2hyrMcqhjnGWzYAHPGDGKuJlppzugjZcobYTPIFv5Dn9NOBUrKxRyPXI5dUb5SqwzYbJNw==",
|
||||||
"license": "SEE LICENSE IN LICENSE.txt",
|
"license": "SEE LICENSE IN LICENSE.txt",
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@angular/core": ">=16.0.0 <22.0.0",
|
"@angular/core": ">=16.0.0 <23.0.0",
|
||||||
"handsontable": "^17.0.0",
|
"handsontable": "^18.0.0",
|
||||||
"rxjs": "^7.0.0"
|
"rxjs": "^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@handsontable/pikaday": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@handsontable/pikaday/-/pikaday-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-1VN6N38t5/DcjJ7y7XUYrDx1LuzvvzlrFdBdMG90Qo1xc8+LXHqbWbsTEm5Ec5gXTEbDEO53vUT35R+2COmOyg==",
|
|
||||||
"license": "(0BSD OR MIT)"
|
|
||||||
},
|
|
||||||
"node_modules/@hapi/hoek": {
|
"node_modules/@hapi/hoek": {
|
||||||
"version": "9.3.0",
|
"version": "9.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
|
||||||
@@ -6087,9 +6082,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6107,9 +6099,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6127,9 +6116,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6147,9 +6133,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6167,9 +6150,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6187,9 +6167,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6207,9 +6184,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7345,9 +7319,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7369,9 +7340,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7393,9 +7361,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7417,9 +7382,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7441,9 +7403,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7465,9 +7424,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7693,9 +7649,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7710,9 +7663,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7727,9 +7677,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7744,9 +7691,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7761,9 +7705,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7778,9 +7719,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7795,9 +7733,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7812,9 +7747,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7829,9 +7761,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7846,9 +7775,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7863,9 +7789,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7880,9 +7803,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7897,9 +7817,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -9072,15 +8989,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/project-service": {
|
"node_modules/@typescript-eslint/project-service": {
|
||||||
"version": "8.59.1",
|
"version": "8.63.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz",
|
||||||
"integrity": "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==",
|
"integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/tsconfig-utils": "^8.59.1",
|
"@typescript-eslint/tsconfig-utils": "^8.63.0",
|
||||||
"@typescript-eslint/types": "^8.59.1",
|
"@typescript-eslint/types": "^8.63.0",
|
||||||
"debug": "^4.4.3"
|
"debug": "^4.4.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -9127,9 +9044,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||||
"version": "8.59.1",
|
"version": "8.63.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz",
|
||||||
"integrity": "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==",
|
"integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
@@ -9207,9 +9124,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/types": {
|
"node_modules/@typescript-eslint/types": {
|
||||||
"version": "8.59.1",
|
"version": "8.63.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz",
|
||||||
"integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==",
|
"integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
@@ -9289,17 +9206,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/utils": {
|
"node_modules/@typescript-eslint/utils": {
|
||||||
"version": "8.59.1",
|
"version": "8.63.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz",
|
||||||
"integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==",
|
"integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.9.1",
|
"@eslint-community/eslint-utils": "^4.9.1",
|
||||||
"@typescript-eslint/scope-manager": "8.59.1",
|
"@typescript-eslint/scope-manager": "8.63.0",
|
||||||
"@typescript-eslint/types": "8.59.1",
|
"@typescript-eslint/types": "8.63.0",
|
||||||
"@typescript-eslint/typescript-estree": "8.59.1"
|
"@typescript-eslint/typescript-estree": "8.63.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -9314,15 +9231,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": {
|
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": {
|
||||||
"version": "8.59.1",
|
"version": "8.63.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz",
|
||||||
"integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==",
|
"integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.59.1",
|
"@typescript-eslint/types": "8.63.0",
|
||||||
"@typescript-eslint/visitor-keys": "8.59.1"
|
"@typescript-eslint/visitor-keys": "8.63.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -9333,17 +9250,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": {
|
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": {
|
||||||
"version": "8.59.1",
|
"version": "8.63.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz",
|
||||||
"integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==",
|
"integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/project-service": "8.59.1",
|
"@typescript-eslint/project-service": "8.63.0",
|
||||||
"@typescript-eslint/tsconfig-utils": "8.59.1",
|
"@typescript-eslint/tsconfig-utils": "8.63.0",
|
||||||
"@typescript-eslint/types": "8.59.1",
|
"@typescript-eslint/types": "8.63.0",
|
||||||
"@typescript-eslint/visitor-keys": "8.59.1",
|
"@typescript-eslint/visitor-keys": "8.63.0",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"minimatch": "^10.2.2",
|
"minimatch": "^10.2.2",
|
||||||
"semver": "^7.7.3",
|
"semver": "^7.7.3",
|
||||||
@@ -9362,14 +9279,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": {
|
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": {
|
||||||
"version": "8.59.1",
|
"version": "8.63.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz",
|
||||||
"integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==",
|
"integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.59.1",
|
"@typescript-eslint/types": "8.63.0",
|
||||||
"eslint-visitor-keys": "^5.0.0"
|
"eslint-visitor-keys": "^5.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -9392,9 +9309,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/utils/node_modules/brace-expansion": {
|
"node_modules/@typescript-eslint/utils/node_modules/brace-expansion": {
|
||||||
"version": "5.0.5",
|
"version": "5.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
@@ -9437,9 +9354,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/utils/node_modules/semver": {
|
"node_modules/@typescript-eslint/utils/node_modules/semver": {
|
||||||
"version": "7.7.4",
|
"version": "7.8.5",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
@@ -10568,15 +10485,6 @@
|
|||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bignumber.js": {
|
|
||||||
"version": "9.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
|
|
||||||
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/binary": {
|
"node_modules/binary": {
|
||||||
"version": "0.3.0",
|
"version": "0.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz",
|
||||||
@@ -13233,15 +13141,6 @@
|
|||||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dompurify": {
|
|
||||||
"version": "3.4.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz",
|
|
||||||
"integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==",
|
|
||||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@types/trusted-types": "^2.0.7"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/domutils": {
|
"node_modules/domutils": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||||
@@ -15404,30 +15303,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/handsontable": {
|
"node_modules/handsontable": {
|
||||||
"version": "17.1.0",
|
"version": "18.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/handsontable/-/handsontable-17.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/handsontable/-/handsontable-18.0.0.tgz",
|
||||||
"integrity": "sha512-3afpoGQT04i11IYge4gZNHp4Io3RFoLi+vVk4yTc2oHGjuBJBhjVVlTnOcYM8N9Ay1ikjSh4T3ZiwFVYEsgJrA==",
|
"integrity": "sha512-U4H6gDrwGTfjahSyoGHhFHZvOZWB7ReQ3iBvJUIcZPdqdppdZ+FSYNovw+3MH1guJfGgRIhoLYnbSm8Gd6Ft8w==",
|
||||||
"license": "SEE LICENSE IN LICENSE.txt",
|
"license": "SEE LICENSE IN LICENSE.txt"
|
||||||
"dependencies": {
|
|
||||||
"@handsontable/pikaday": "^1.0.0",
|
|
||||||
"dompurify": "^3.1.7",
|
|
||||||
"moment": "2.30.1",
|
|
||||||
"numbro": "2.5.0"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"hyperformula": "^3.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/handsontable/node_modules/hyperformula": {
|
|
||||||
"version": "3.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/hyperformula/-/hyperformula-3.3.0.tgz",
|
|
||||||
"integrity": "sha512-Mkc7AxP9WQDM4xHo1/XpAwzcpMLkwMand4HO9rfRzP502TBzoR1Z96zoCzRrwXodLSVyh+MD7FvzRVALDxmRFQ==",
|
|
||||||
"license": "GPL-3.0-only",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"chevrotain": "^6.5.0",
|
|
||||||
"tiny-emitter": "^2.1.0"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/has-flag": {
|
"node_modules/has-flag": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
@@ -19192,9 +19071,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mocha": {
|
"node_modules/mocha": {
|
||||||
"version": "11.7.5",
|
"version": "11.7.6",
|
||||||
"resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.5.tgz",
|
"resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.6.tgz",
|
||||||
"integrity": "sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig==",
|
"integrity": "sha512-nS9xOGbw2I3cjCpxwZAEJ9xK9lmJ08vEkQvLtz4du9ZrF9UrjRpeJGiIgl2Z+Qs++pmB4ecDe48Fwsh+j+j7xA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
@@ -19238,9 +19117,9 @@
|
|||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/mocha/node_modules/brace-expansion": {
|
"node_modules/mocha/node_modules/brace-expansion": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
@@ -19297,10 +19176,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mocha/node_modules/js-yaml": {
|
"node_modules/mocha/node_modules/js-yaml": {
|
||||||
"version": "4.1.1",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/nodeca"
|
||||||
|
}
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -20170,18 +20059,6 @@
|
|||||||
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/numbro": {
|
|
||||||
"version": "2.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/numbro/-/numbro-2.5.0.tgz",
|
|
||||||
"integrity": "sha512-xDcctDimhzko/e+y+Q2/8i3qNC9Svw1QgOkSkQoO0kIPI473tR9QRbo2KP88Ty9p8WbPy+3OpTaAIzehtuHq+A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"bignumber.js": "^8 || ^9"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@@ -21323,6 +21200,22 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prettier": {
|
||||||
|
"version": "3.8.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz",
|
||||||
|
"integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"prettier": "bin/prettier.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pretty-bytes": {
|
"node_modules/pretty-bytes": {
|
||||||
"version": "5.6.0",
|
"version": "5.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
|
||||||
@@ -21445,6 +21338,26 @@
|
|||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/proxy-agent-negotiate": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"kerberos": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"kerberos": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/proxy-agent/node_modules/lru-cache": {
|
"node_modules/proxy-agent/node_modules/lru-cache": {
|
||||||
"version": "7.18.3",
|
"version": "7.18.3",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
|
||||||
@@ -21591,6 +21504,50 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/puppeteer-core/node_modules/agent-base": {
|
||||||
|
"version": "9.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz",
|
||||||
|
"integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/puppeteer-core/node_modules/data-uri-to-buffer": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-6UHfyCux51b8PTGDgveqtz1tvphBku5DrMKKJbFAZAJOI2zsjDpDoYE1+QGj7FOMS4BdTFNJsJiR3zEB0xH0yQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/puppeteer-core/node_modules/degenerator": {
|
||||||
|
"version": "7.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/degenerator/-/degenerator-7.0.1.tgz",
|
||||||
|
"integrity": "sha512-ABErK0IefDSyHjlPH7WUEenIAX2rPPnrDcDM+TS3z3+zu9TfyKKi07BQM+8rmxpdE2y1v5fjjdoAS/x4D2U60w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"ast-types": "^0.13.4",
|
||||||
|
"escodegen": "^2.1.0",
|
||||||
|
"esprima": "^4.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"quickjs-wasi": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/puppeteer-core/node_modules/devtools-protocol": {
|
"node_modules/puppeteer-core/node_modules/devtools-protocol": {
|
||||||
"version": "0.0.1624250",
|
"version": "0.0.1624250",
|
||||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1624250.tgz",
|
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1624250.tgz",
|
||||||
@@ -21598,6 +21555,149 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/puppeteer-core/node_modules/get-uri": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-uri/-/get-uri-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-/5N/P4Lrh0p/mDwlDRi7Y1+P2o/OyzZI3l6Iz1Ov6XXwwm1y3RlZLuo3gVgML99djrEDtV980bBxSuOeHLk8ww==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"basic-ftp": "^5.3.1",
|
||||||
|
"data-uri-to-buffer": "8.0.0",
|
||||||
|
"debug": "^4.3.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/puppeteer-core/node_modules/http-proxy-agent": {
|
||||||
|
"version": "9.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz",
|
||||||
|
"integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "9.0.0",
|
||||||
|
"debug": "^4.3.4",
|
||||||
|
"proxy-agent-negotiate": "1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/puppeteer-core/node_modules/https-proxy-agent": {
|
||||||
|
"version": "9.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz",
|
||||||
|
"integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "9.0.0",
|
||||||
|
"debug": "^4.3.4",
|
||||||
|
"proxy-agent-negotiate": "1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/puppeteer-core/node_modules/lru-cache": {
|
||||||
|
"version": "7.18.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
|
||||||
|
"integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/puppeteer-core/node_modules/pac-proxy-agent": {
|
||||||
|
"version": "9.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-9.1.0.tgz",
|
||||||
|
"integrity": "sha512-1aU+1mpj3DrQPfo3gh+3Gap3G5x+axnMx1P/y0ZF2ch7kb2meyOCAH8K2k9d27ROsTE7TnAerzxqF9aon2jqnA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "9.0.0",
|
||||||
|
"debug": "^4.3.4",
|
||||||
|
"get-uri": "8.0.1",
|
||||||
|
"http-proxy-agent": "9.1.0",
|
||||||
|
"https-proxy-agent": "9.1.0",
|
||||||
|
"pac-resolver": "9.0.1",
|
||||||
|
"quickjs-wasi": "^2.2.0",
|
||||||
|
"socks-proxy-agent": "10.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/puppeteer-core/node_modules/pac-resolver": {
|
||||||
|
"version": "9.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-9.0.1.tgz",
|
||||||
|
"integrity": "sha512-lJbS008tmkj08VhoM8Hzuv/VE5tK9MS0OIQ/7+s0lIF+BYhiQWFYzkSpML7lXs9iBu2jfmzBTLzhe9n6BX+dYw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"degenerator": "7.0.1",
|
||||||
|
"netmask": "^2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"quickjs-wasi": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/puppeteer-core/node_modules/proxy-agent": {
|
||||||
|
"version": "8.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-8.0.2.tgz",
|
||||||
|
"integrity": "sha512-idLLRewuemWd7GH/BDJzGiB0dWGfT2SQs3jy6NtZtGWU9uPTTSdeC1/cdbqLwgzhfv027daGFuXX426e2Eg20A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "9.0.0",
|
||||||
|
"debug": "^4.3.4",
|
||||||
|
"http-proxy-agent": "9.1.0",
|
||||||
|
"https-proxy-agent": "9.1.0",
|
||||||
|
"lru-cache": "^7.14.1",
|
||||||
|
"pac-proxy-agent": "9.1.0",
|
||||||
|
"proxy-from-env": "^2.0.0",
|
||||||
|
"socks-proxy-agent": "10.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/puppeteer-core/node_modules/socks-proxy-agent": {
|
||||||
|
"version": "10.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-10.1.0.tgz",
|
||||||
|
"integrity": "sha512-WlMj/67cEJ6MDI1OcsnjuYKDNDoyPCCYZ249kuuXPiMDw9F8PXkVaQ7YWu3siTydfQ/4BEZcvGzu+aYvz7dDCQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "9.0.0",
|
||||||
|
"debug": "^4.3.4",
|
||||||
|
"socks": "^2.8.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/puppeteer-core/node_modules/ws": {
|
"node_modules/puppeteer-core/node_modules/ws": {
|
||||||
"version": "8.21.0",
|
"version": "8.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||||
@@ -21673,6 +21773,15 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/quickjs-wasi": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/quickjs-wasi/-/quickjs-wasi-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-zQxXmQMrEoD3S+jQdYsloq4qAuaxKFHZj6hHqOYGwB2iQZH+q9e/lf5zQPXCKOk0WJuAjzRFbO4KwHIp2D05Iw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/ramda": {
|
"node_modules/ramda": {
|
||||||
"version": "0.29.1",
|
"version": "0.29.1",
|
||||||
"resolved": "https://registry.npmjs.org/ramda/-/ramda-0.29.1.tgz",
|
"resolved": "https://registry.npmjs.org/ramda/-/ramda-0.29.1.tgz",
|
||||||
@@ -25035,9 +25144,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/vis-data": {
|
"node_modules/vis-data": {
|
||||||
"version": "8.0.3",
|
"version": "8.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/vis-data/-/vis-data-8.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/vis-data/-/vis-data-8.0.4.tgz",
|
||||||
"integrity": "sha512-jhnb6rJNqkKR1Qmlay0VuDXY9ZlvAnYN1udsrP4U+krgZEq7C0yNSKdZqmnCe13mdnf9AdVcdDGFOzy2mpPoqw==",
|
"integrity": "sha512-TsN0sMHqIRpdfg6TNPtfdINpkgxtnQP6JNWCaiSwvou5seXqKiP5eERkaBg+Y56wyJ4FZTeOEs/dEmWEPrpltQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "(Apache-2.0 OR MIT)",
|
"license": "(Apache-2.0 OR MIT)",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
@@ -25046,7 +25155,7 @@
|
|||||||
"url": "https://opencollective.com/visjs"
|
"url": "https://opencollective.com/visjs"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"uuid": "^3.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^13.0.0",
|
"uuid": "^3.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^13.0.0 || ^14.0.0",
|
||||||
"vis-util": ">=6.0.0"
|
"vis-util": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+8
-3
@@ -17,7 +17,11 @@
|
|||||||
"deploy_viya": "rsync -avhe ssh ./dist/* --delete ${npm_config_account}@sas.4gl.io:/var/www/html/${npm_config_account}/dc/dev",
|
"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",
|
"deploy_sasjs": "rsync -avhe ssh ./dist/* --delete root@${npm_config_account}.4gl.io:/var/www/html/dc/dev",
|
||||||
"viyabuild": "cd build; ./viyabuild.sh",
|
"viyabuild": "cd build; ./viyabuild.sh",
|
||||||
"lint": "cd .. && npm run lint",
|
"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\"",
|
||||||
"test": "npx ng test",
|
"test": "npx ng test",
|
||||||
"test:headless": "npx ng test --no-watch --no-progress --browsers ChromeHeadlessCI",
|
"test:headless": "npx ng test --no-watch --no-progress --browsers ChromeHeadlessCI",
|
||||||
"watch": "ng test watch=true",
|
"watch": "ng test watch=true",
|
||||||
@@ -50,7 +54,7 @@
|
|||||||
"@clr/angular": "file:libraries/clr-angular-17.9.0.tgz",
|
"@clr/angular": "file:libraries/clr-angular-17.9.0.tgz",
|
||||||
"@clr/icons": "^13.0.2",
|
"@clr/icons": "^13.0.2",
|
||||||
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
|
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
|
||||||
"@handsontable/angular-wrapper": "^17.1.0",
|
"@handsontable/angular-wrapper": "^18.0.0",
|
||||||
"@sasjs/adapter": "^4.17.0",
|
"@sasjs/adapter": "^4.17.0",
|
||||||
"@sasjs/utils": "^3.5.3",
|
"@sasjs/utils": "^3.5.3",
|
||||||
"@sheet/crypto": "file:libraries/sheet-crypto.tgz",
|
"@sheet/crypto": "file:libraries/sheet-crypto.tgz",
|
||||||
@@ -63,7 +67,7 @@
|
|||||||
"d3-graphviz": "^5.0.2",
|
"d3-graphviz": "^5.0.2",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"fs-extra": "^7.0.1",
|
"fs-extra": "^7.0.1",
|
||||||
"handsontable": "^17.1.0",
|
"handsontable": "^18.0.0",
|
||||||
"https-browserify": "1.0.0",
|
"https-browserify": "1.0.0",
|
||||||
"hyperformula": "^2.5.0",
|
"hyperformula": "^2.5.0",
|
||||||
"iconv-lite": "^0.5.0",
|
"iconv-lite": "^0.5.0",
|
||||||
@@ -126,6 +130,7 @@
|
|||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"mochawesome": "^7.1.3",
|
"mochawesome": "^7.1.3",
|
||||||
"mutationobserver-shim": "^0.3.3",
|
"mutationobserver-shim": "^0.3.3",
|
||||||
|
"prettier": "3.8.4",
|
||||||
"replace-in-file": "^6.3.5",
|
"replace-in-file": "^6.3.5",
|
||||||
"rimraf": "3.0.2",
|
"rimraf": "3.0.2",
|
||||||
"ts-loader": "^9.2.8",
|
"ts-loader": "^9.2.8",
|
||||||
|
|||||||
@@ -55,7 +55,12 @@ export interface HandsontableStaticConfig {
|
|||||||
* Cached viyaApi collections, search and selected endpoint
|
* Cached viyaApi collections, search and selected endpoint
|
||||||
*/
|
*/
|
||||||
export const globals: {
|
export const globals: {
|
||||||
embed: boolean
|
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'
|
||||||
rootParam: string
|
rootParam: string
|
||||||
dcLib: string
|
dcLib: string
|
||||||
xlmaps: XLMapListItem[]
|
xlmaps: XLMapListItem[]
|
||||||
@@ -71,6 +76,7 @@ export const globals: {
|
|||||||
[key: string]: any
|
[key: string]: any
|
||||||
} = {
|
} = {
|
||||||
embed: false,
|
embed: false,
|
||||||
|
vaApplyMode: 'live',
|
||||||
rootParam: <string>'',
|
rootParam: <string>'',
|
||||||
dcLib: '',
|
dcLib: '',
|
||||||
xlmaps: [],
|
xlmaps: [],
|
||||||
|
|||||||
@@ -256,6 +256,26 @@
|
|||||||
|
|
||||||
<!-- <app-terms *ngIf="showRegistration"></app-terms> -->
|
<!-- <app-terms *ngIf="showRegistration"></app-terms> -->
|
||||||
|
|
||||||
|
<!-- VA embed, back to Editor button -->
|
||||||
|
<div
|
||||||
|
*ngIf="embed === 'va' && vaEditorLibds && !isMainRoute('/editor')"
|
||||||
|
class="va-back-bar"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-primary"
|
||||||
|
(click)="backToEditor()"
|
||||||
|
>
|
||||||
|
<clr-icon
|
||||||
|
aria-hidden="true"
|
||||||
|
shape="caret"
|
||||||
|
dir="left"
|
||||||
|
size="16"
|
||||||
|
></clr-icon>
|
||||||
|
Back to Edit table
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<router-outlet *ngIf="startupDataLoaded"></router-outlet>
|
<router-outlet *ngIf="startupDataLoaded"></router-outlet>
|
||||||
|
|
||||||
<app-login></app-login>
|
<app-login></app-login>
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
.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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Location } from '@angular/common'
|
|||||||
import '@clr/icons'
|
import '@clr/icons'
|
||||||
import '@clr/icons/shapes/all-shapes'
|
import '@clr/icons/shapes/all-shapes'
|
||||||
import { globals } from './_globals'
|
import { globals } from './_globals'
|
||||||
|
import { parseEmbedParam } from './shared/utils/parse-embed-param'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { EventService } from './services/event.service'
|
import { EventService } from './services/event.service'
|
||||||
import { AppService } from './services/app.service'
|
import { AppService } from './services/app.service'
|
||||||
@@ -145,13 +146,10 @@ export class AppComponent {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const hashQuery = window.location.hash.split('?')[1]
|
const hashQuery = window.location.hash.split('?')[1]
|
||||||
if (hashQuery) {
|
if (hashQuery && new URLSearchParams(hashQuery).get('embed') !== null) {
|
||||||
const embedParam = new URLSearchParams(hashQuery).get('embed')
|
const embed = parseEmbedParam(hashQuery)
|
||||||
if (embedParam !== null) {
|
globals.embed = embed
|
||||||
const isEmbed = embedParam !== 'false'
|
this.embed = embed
|
||||||
globals.embed = isEmbed
|
|
||||||
this.embed = isEmbed
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.subscribeToShowAbortModal()
|
this.subscribeToShowAbortModal()
|
||||||
@@ -354,4 +352,23 @@ export class AppComponent {
|
|||||||
public openLicencingPage() {
|
public openLicencingPage() {
|
||||||
this.router.navigateByUrl('/licensing/update')
|
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' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,15 @@ import { RouterModule, Routes } from '@angular/router'
|
|||||||
|
|
||||||
import { NotFoundComponent } from './not-found/not-found.component'
|
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 { 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
|
* Defining routes
|
||||||
@@ -17,8 +25,7 @@ export const ROUTES: Routes = [
|
|||||||
{ path: '', redirectTo: 'home', pathMatch: 'full' },
|
{ path: '', redirectTo: 'home', pathMatch: 'full' },
|
||||||
{
|
{
|
||||||
path: 'view',
|
path: 'view',
|
||||||
loadChildren: () =>
|
loadChildren: () => ViewerModule
|
||||||
import('./viewer/viewer.module').then((m) => m.ViewerModule)
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
@@ -30,42 +37,36 @@ export const ROUTES: Routes = [
|
|||||||
{ path: '', pathMatch: 'full', redirectTo: 'toapprove' },
|
{ path: '', pathMatch: 'full', redirectTo: 'toapprove' },
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
loadChildren: () =>
|
loadChildren: () => ReviewModule
|
||||||
import('./review/review.module').then((m) => m.ReviewModule)
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'licensing',
|
path: 'licensing',
|
||||||
loadChildren: () =>
|
loadChildren: () => LicensingModule
|
||||||
import('./licensing/licensing.module').then((m) => m.LicensingModule)
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'home',
|
path: 'home',
|
||||||
loadChildren: () => import('./home/home.module').then((m) => m.HomeModule)
|
loadChildren: () => HomeModule
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Load editor module with subroutes
|
* Load editor module with subroutes
|
||||||
*/
|
*/
|
||||||
path: 'editor',
|
path: 'editor',
|
||||||
loadChildren: () =>
|
loadChildren: () => EditorModule
|
||||||
import('./editor/editor.module').then((m) => m.EditorModule)
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'stage',
|
path: 'stage',
|
||||||
loadChildren: () =>
|
loadChildren: () => StageModule
|
||||||
import('./stage/stage.module').then((m) => m.StageModule)
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'system',
|
path: 'system',
|
||||||
loadChildren: () =>
|
loadChildren: () => SystemModule
|
||||||
import('./system/system.module').then((m) => m.SystemModule)
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'deploy',
|
path: 'deploy',
|
||||||
loadChildren: () =>
|
loadChildren: () => DeployModule
|
||||||
import('./deploy/deploy.module').then((m) => m.DeployModule)
|
|
||||||
},
|
},
|
||||||
{ path: '**', component: NotFoundComponent }
|
{ path: '**', component: NotFoundComponent }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -257,6 +257,7 @@
|
|||||||
>
|
>
|
||||||
<ng-container *ngIf="hotTable.readOnly && !uploadPreview">
|
<ng-container *ngIf="hotTable.readOnly && !uploadPreview">
|
||||||
<button
|
<button
|
||||||
|
*ngIf="!isVaEmbed"
|
||||||
type="button"
|
type="button"
|
||||||
class="btnView btn icon-collapse btn-sm btn-icon btn-block btn-dimmed"
|
class="btnView btn icon-collapse btn-sm btn-icon btn-block btn-dimmed"
|
||||||
(click)="openQb()"
|
(click)="openQb()"
|
||||||
@@ -435,7 +436,9 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<clr-tooltip
|
<clr-tooltip
|
||||||
*ngIf="tableTrue && !restrictions.removeAddRecordButton"
|
*ngIf="
|
||||||
|
tableTrue && !restrictions.removeAddRecordButton && !isVaEmbed
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
clrTooltipTrigger
|
clrTooltipTrigger
|
||||||
@@ -466,6 +469,64 @@
|
|||||||
>
|
>
|
||||||
</clr-tooltip-content>
|
</clr-tooltip-content>
|
||||||
</clr-tooltip>
|
</clr-tooltip>
|
||||||
|
|
||||||
|
<!-- VA data-driven content mode: filter controls only. Edit/Submit
|
||||||
|
use the normal top toolbar (same as embed=true). VA opens
|
||||||
|
read-only and filters live with the report; filtering
|
||||||
|
repopulates the grid, so it is held while editing (change shown
|
||||||
|
pending) and resumes on return to read-only. -->
|
||||||
|
<ng-container *ngIf="isVaEmbed">
|
||||||
|
<div class="va-filter-controls w-100 mt-2-i">
|
||||||
|
<!-- Read-only: auto-apply toggle + manual Apply (confirm mode).
|
||||||
|
Hidden while editing (filtering is held). -->
|
||||||
|
<ng-container *ngIf="hotTable.readOnly">
|
||||||
|
<label class="va-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
[checked]="vaAutoApply"
|
||||||
|
(change)="toggleVaAutoApply()"
|
||||||
|
/>
|
||||||
|
Auto-apply VA filters
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
*ngIf="!vaAutoApply"
|
||||||
|
[disabled]="
|
||||||
|
vaFilterStatus !== 'pending' &&
|
||||||
|
vaFilterStatus !== 'loading'
|
||||||
|
"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-primary"
|
||||||
|
(click)="applyPendingVaFilter()"
|
||||||
|
>
|
||||||
|
Apply filter
|
||||||
|
</button>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Edit mode: filtering is held so edits aren't wiped. -->
|
||||||
|
<span *ngIf="!hotTable.readOnly" class="va-disabled-text">
|
||||||
|
VA filtering paused while editing
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Persistent live region (shown in both modes) so a filter
|
||||||
|
change that arrives while editing is announced as pending. -->
|
||||||
|
<span class="va-filter-status" role="status" aria-live="polite">
|
||||||
|
<span
|
||||||
|
*ngIf="vaFilterStatus === 'pending'"
|
||||||
|
class="va-pending-text"
|
||||||
|
>
|
||||||
|
Filter change pending
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
*ngIf="vaFilterStatus === 'loading'"
|
||||||
|
class="va-loading-text"
|
||||||
|
>
|
||||||
|
Loading filter…
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
*ngIf="
|
*ngIf="
|
||||||
licenceState.value.editor_rows_allowed !== Infinity &&
|
licenceState.value.editor_rows_allowed !== Infinity &&
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
ViewEncapsulation
|
ViewEncapsulation
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { ActivatedRoute, Router } from '@angular/router'
|
import { ActivatedRoute, Router } from '@angular/router'
|
||||||
import Handsontable from 'handsontable'
|
import Handsontable, { CellRange } from 'handsontable'
|
||||||
import { Subject, Subscription } from 'rxjs'
|
import { Subject, Subscription } from 'rxjs'
|
||||||
import { sanitiseForSas } from '../shared/utils/sanitise'
|
import { sanitiseForSas } from '../shared/utils/sanitise'
|
||||||
import { SasStoreService } from '../services/sas-store.service'
|
import { SasStoreService } from '../services/sas-store.service'
|
||||||
@@ -21,7 +21,6 @@ type AOA = any[][]
|
|||||||
import { HotTableComponent } from '@handsontable/angular-wrapper'
|
import { HotTableComponent } from '@handsontable/angular-wrapper'
|
||||||
import { UploadFile } from '@sasjs/adapter'
|
import { UploadFile } from '@sasjs/adapter'
|
||||||
import { isSpecialMissing } from '@sasjs/utils/input/validators'
|
import { isSpecialMissing } from '@sasjs/utils/input/validators'
|
||||||
import CellRange from 'handsontable/3rdparty/walkontable/src/cell/range'
|
|
||||||
import { CellValidationSource } from '../models/CellValidationSource'
|
import { CellValidationSource } from '../models/CellValidationSource'
|
||||||
import { FileUploader } from '../models/FileUploader.class'
|
import { FileUploader } from '../models/FileUploader.class'
|
||||||
import { FilterGroup, FilterQuery } from '../models/FilterQuery'
|
import { FilterGroup, FilterQuery } from '../models/FilterQuery'
|
||||||
@@ -34,7 +33,7 @@ import {
|
|||||||
Version
|
Version
|
||||||
} from '../models/sas/editors-getdata.model'
|
} from '../models/sas/editors-getdata.model'
|
||||||
import { DataFormat } from '../models/sas/common/DateFormat'
|
import { DataFormat } from '../models/sas/common/DateFormat'
|
||||||
import { Approver, ExcelRule } from '../models/TableData'
|
import { Approver, ExcelRule, QueryClause } from '../models/TableData'
|
||||||
import { QueryComponent } from '../query/query.component'
|
import { QueryComponent } from '../query/query.component'
|
||||||
import { EventService } from '../services/event.service'
|
import { EventService } from '../services/event.service'
|
||||||
import { HelperService } from '../services/helper.service'
|
import { HelperService } from '../services/helper.service'
|
||||||
@@ -47,9 +46,15 @@ import { DQRule } from '../shared/dc-validator/models/dq-rules.model'
|
|||||||
import { getHotDataSchema } from '../shared/dc-validator/utils/getHotDataSchema'
|
import { getHotDataSchema } from '../shared/dc-validator/utils/getHotDataSchema'
|
||||||
import { excelRound } from '../shared/dc-validator/utils/excelRound'
|
import { excelRound } from '../shared/dc-validator/utils/excelRound'
|
||||||
import { isEmpty } from '../shared/dc-validator/utils/isEmpty'
|
import { isEmpty } from '../shared/dc-validator/utils/isEmpty'
|
||||||
|
import { 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 { globals } from '../_globals'
|
||||||
import { UploadStaterComponent } from './components/upload-stater/upload-stater.component'
|
import { UploadStaterComponent } from './components/upload-stater/upload-stater.component'
|
||||||
import { DynamicExtendedCellValidation } from './models/dynamicExtendedCellValidation'
|
import {
|
||||||
|
DynamicCellValidation,
|
||||||
|
DynamicExtendedCellValidation
|
||||||
|
} from './models/dynamicExtendedCellValidation'
|
||||||
import { EditRecordInputFocusedEvent } from './models/edit-record/edit-record-events'
|
import { EditRecordInputFocusedEvent } from './models/edit-record/edit-record-events'
|
||||||
import { EditorRestrictions } from './models/editor-restrictions.model'
|
import { EditorRestrictions } from './models/editor-restrictions.model'
|
||||||
import { parseTableColumns } from './utils/grid.utils'
|
import { parseTableColumns } from './utils/grid.utils'
|
||||||
@@ -61,6 +66,8 @@ import {
|
|||||||
import { LicenceService } from '../services/licence.service'
|
import { LicenceService } from '../services/licence.service'
|
||||||
import { FileUploadEncoding } from '../models/FileUploadEncoding'
|
import { FileUploadEncoding } from '../models/FileUploadEncoding'
|
||||||
import { SpreadsheetService } from '../services/spreadsheet.service'
|
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 { UploadFileResponse } from '../models/UploadFile'
|
||||||
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
|
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
|
||||||
import { ParseResult } from '../models/ParseResult.interface'
|
import { ParseResult } from '../models/ParseResult.interface'
|
||||||
@@ -156,8 +163,11 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
items: {
|
items: {
|
||||||
edit_row: {
|
edit_row: {
|
||||||
name: 'Edit row',
|
name: 'Edit row',
|
||||||
hidden() {
|
// HOT 18's MenuItemConfig types `hidden` as a plain `() => boolean` with
|
||||||
const hot: Handsontable.Core = this
|
// no `this` type, so an object-literal method here has `this` inferred
|
||||||
|
// as the surrounding item config unless declared explicitly.
|
||||||
|
hidden(this: Handsontable.Core) {
|
||||||
|
const hot = this
|
||||||
|
|
||||||
// Hide editing actions in read-only (view) mode.
|
// Hide editing actions in read-only (view) mode.
|
||||||
if (hot.getSettings().readOnly) return true
|
if (hot.getSettings().readOnly) return true
|
||||||
@@ -238,6 +248,20 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
redo: {
|
redo: {
|
||||||
name: 'Redo',
|
name: 'Redo',
|
||||||
hidden: () => this.hotTable.readOnly === true
|
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'
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -261,6 +285,11 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
public libTab: string | undefined
|
public libTab: string | undefined
|
||||||
public queryFilter: any
|
public queryFilter: any
|
||||||
public _query: Subscription | undefined
|
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 whereString: string | undefined
|
||||||
public clauses: any
|
public clauses: any
|
||||||
@@ -282,6 +311,44 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
get embed() {
|
get embed() {
|
||||||
return globals.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 tableTrue: boolean | undefined
|
||||||
public saveLoading = false
|
public saveLoading = false
|
||||||
public approvers: string[] = []
|
public approvers: string[] = []
|
||||||
@@ -394,30 +461,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
message: string
|
message: string
|
||||||
} = { open: false, title: '', message: '' }
|
} = { open: false, title: '', message: '' }
|
||||||
private confirmModalResolver: ((v: boolean) => void) | null = null
|
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: {
|
public disabledBasicDynamicCellValidationMap: {
|
||||||
row: number
|
row: number
|
||||||
@@ -429,6 +472,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
private ariaObserver: MutationObserver | undefined
|
private ariaObserver: MutationObserver | undefined
|
||||||
private ariaCheckInterval: any | undefined
|
private ariaCheckInterval: any | undefined
|
||||||
|
private gridResizeObserver: ResizeObserver | undefined
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private licenceService: LicenceService,
|
private licenceService: LicenceService,
|
||||||
@@ -440,7 +484,9 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private sasService: SasService,
|
private sasService: SasService,
|
||||||
private cdf: ChangeDetectorRef,
|
private cdf: ChangeDetectorRef,
|
||||||
private spreadsheetService: SpreadsheetService
|
private spreadsheetService: SpreadsheetService,
|
||||||
|
private vaMessaging: VaMessagingService,
|
||||||
|
private vaFilter: VaFilterService
|
||||||
) {
|
) {
|
||||||
this.parseRestrictions()
|
this.parseRestrictions()
|
||||||
this.setRestrictions()
|
this.setRestrictions()
|
||||||
@@ -934,16 +980,40 @@ 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) {
|
editTable(previewEdit?: boolean, newRow?: boolean) {
|
||||||
this.toggleHotPlugin('contextMenu', true)
|
this.toggleHotPlugin('contextMenu', true)
|
||||||
|
|
||||||
const hot = this.hotInstance
|
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 columnSorting = hot.getPlugin('multiColumnSorting')
|
||||||
const columnSortConfig = columnSorting.getSortConfig()
|
const sortConfigs = this.getCurrentSortConfigs()
|
||||||
const sortConfigs = Array.isArray(columnSortConfig)
|
|
||||||
? columnSortConfig
|
|
||||||
: [columnSortConfig]
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!previewEdit) {
|
if (!previewEdit) {
|
||||||
@@ -1037,6 +1107,16 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.checkRowLimit()
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1283,7 +1363,11 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
hot.updateSettings(
|
hot.updateSettings(
|
||||||
{
|
{
|
||||||
data: this.dataSource,
|
data: this.dataSource,
|
||||||
colHeaders: this.headerColumns,
|
colHeaders: getDisplayColHeaders(
|
||||||
|
this.headerColumns,
|
||||||
|
this.cols,
|
||||||
|
this.useLabels
|
||||||
|
),
|
||||||
columns: this.cellValidation,
|
columns: this.cellValidation,
|
||||||
modifyColWidth: function (width: number, col: number) {
|
modifyColWidth: function (width: number, col: number) {
|
||||||
if (col === 0) {
|
if (col === 0) {
|
||||||
@@ -1362,7 +1446,9 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
hot.removeCellMeta(rowIndex, col, 'valid')
|
hot.removeCellMeta(rowIndex, col, 'valid')
|
||||||
hot.removeCellMeta(rowIndex, col, 'dupKey')
|
hot.removeCellMeta(rowIndex, col, 'dupKey')
|
||||||
// Remove our custom class from cell metadata
|
// Remove our custom class from cell metadata
|
||||||
const cellMeta = hot.getCellMeta(rowIndex, col)
|
// getCellMeta<M>() defaults to Record<string, unknown> in HOT 18; pin it to
|
||||||
|
// CellMeta so `.className` keeps its real string | string[] type below.
|
||||||
|
const cellMeta = hot.getCellMeta<Handsontable.CellMeta>(rowIndex, col)
|
||||||
if (cellMeta.className) {
|
if (cellMeta.className) {
|
||||||
let cleanedClassName: string
|
let cleanedClassName: string
|
||||||
if (Array.isArray(cellMeta.className)) {
|
if (Array.isArray(cellMeta.className)) {
|
||||||
@@ -1464,7 +1550,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
for (const entry of this.cellValidationSource) {
|
for (const entry of this.cellValidationSource) {
|
||||||
const colSource = entry.values.map(
|
const colSource = entry.values.map(
|
||||||
(el: any) => el[this.cellValidationFields.RAW_VALUE]
|
(el: DynamicCellValidation) => el.RAW_VALUE
|
||||||
)
|
)
|
||||||
|
|
||||||
hot.batch(() => {
|
hot.batch(() => {
|
||||||
@@ -1480,7 +1566,9 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
if (!cellSource) cellSource = []
|
if (!cellSource) cellSource = []
|
||||||
|
|
||||||
const combinedSource = [...new Set([...cellSource, ...colSource])]
|
const combinedSource = [
|
||||||
|
...new Set([...cellSource, ...colSource])
|
||||||
|
] as string[]
|
||||||
|
|
||||||
this.currentEditRecordValidator?.updateRule(entry.col, {
|
this.currentEditRecordValidator?.updateRule(entry.col, {
|
||||||
source: combinedSource
|
source: combinedSource
|
||||||
@@ -1649,7 +1737,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const forceValue = valuesForCol.find(
|
const forceValue = valuesForCol.find(
|
||||||
(x: DynamicExtendedCellValidation) => x.FORCE_FLAG === 1
|
(x: DynamicExtendedCellValidation) => x.FORCED_VALUE === 1
|
||||||
)
|
)
|
||||||
|
|
||||||
if (forceValue) {
|
if (forceValue) {
|
||||||
@@ -1693,30 +1781,15 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
rowOverride ? rowOverride : cellValidationEntry.row,
|
rowOverride ? rowOverride : cellValidationEntry.row,
|
||||||
cellValidationEntry.col
|
cellValidationEntry.col
|
||||||
)
|
)
|
||||||
const valueIndex = (cellValidationEntry.values.find(
|
const valueIndex = (
|
||||||
(x) => x[this.cellValidationFields.RAW_VALUE] === cellValue
|
cellValidationEntry.values.find((x) => x.RAW_VALUE === cellValue) || {}
|
||||||
) || [])[this.cellValidationFields.DISPLAY_INDEX]
|
).DISPLAY_INDEX
|
||||||
|
|
||||||
const filteredValues = cellValidationEntry.extended_values?.filter(
|
return (
|
||||||
(x) => x[0] === valueIndex
|
cellValidationEntry.extended_values?.filter(
|
||||||
|
(x) => x.DISPLAY_INDEX === 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() {
|
public checkSave() {
|
||||||
@@ -1760,7 +1833,11 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
hot.updateSettings(
|
hot.updateSettings(
|
||||||
{
|
{
|
||||||
data: this.dataSource,
|
data: this.dataSource,
|
||||||
colHeaders: this.headerColumns,
|
colHeaders: getDisplayColHeaders(
|
||||||
|
this.headerColumns,
|
||||||
|
this.cols,
|
||||||
|
this.useLabels
|
||||||
|
),
|
||||||
columns: this.cellValidation,
|
columns: this.cellValidation,
|
||||||
modifyColWidth: function (width: number, col: number) {
|
modifyColWidth: function (width: number, col: number) {
|
||||||
if (width > 500) return 500
|
if (width > 500) return 500
|
||||||
@@ -2120,29 +2197,38 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
toggleHotPlugin(pluginName: string, enable: boolean) {
|
toggleHotPlugin(pluginName: string, enable: boolean) {
|
||||||
const hot = this.hotInstance
|
const hot = this.hotInstance
|
||||||
|
|
||||||
hot.batch(() => {
|
const contextMenuPlugin = hot.getPlugin<any>(pluginName)
|
||||||
const contextMenuPlugin = hot.getPlugin<any>(pluginName)
|
|
||||||
|
|
||||||
if (!contextMenuPlugin) {
|
if (!contextMenuPlugin) {
|
||||||
console.warn(
|
console.warn(
|
||||||
'Toggle Hot Plugin failed - Plugin named: ' +
|
'Toggle Hot Plugin failed - Plugin named: ' +
|
||||||
pluginName +
|
pluginName +
|
||||||
' - could not be found.'
|
' - could not be found.'
|
||||||
)
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
// The instance may be destroyed/rebuilt within this 100ms window (e.g. a
|
||||||
|
// VA filter reload repopulates the grid). A destroyed plugin has its `hot`
|
||||||
|
// reference deleted, so enablePlugin()/disablePlugin() would throw on
|
||||||
|
// `this.hot.getSettings()`. Bail if the instance is gone or was swapped.
|
||||||
|
if (
|
||||||
|
hot.isDestroyed ||
|
||||||
|
this.hotInstance !== hot ||
|
||||||
|
!contextMenuPlugin.hot
|
||||||
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
if (enable) {
|
||||||
if (enable) {
|
contextMenuPlugin.enablePlugin()
|
||||||
contextMenuPlugin.enablePlugin()
|
} else {
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
contextMenuPlugin.disablePlugin()
|
contextMenuPlugin.disablePlugin()
|
||||||
}, 100)
|
}
|
||||||
|
|
||||||
hot.render()
|
hot.render()
|
||||||
})
|
}, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
private dynamicCellValidationDisabled(row: number, col: number) {
|
private dynamicCellValidationDisabled(row: number, col: number) {
|
||||||
@@ -2220,10 +2306,12 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
if (validationSourceIndex > -1) {
|
if (validationSourceIndex > -1) {
|
||||||
let colSource = this.cellValidationSource[
|
let colSource = this.cellValidationSource[
|
||||||
validationSourceIndex
|
validationSourceIndex
|
||||||
].values.map((el) => el[this.cellValidationFields.RAW_VALUE])
|
].values.map((el) => el.RAW_VALUE)
|
||||||
|
|
||||||
|
// `.source` (dropdown/autocomplete list) is typed unknown[] | Function here since
|
||||||
|
// getCellMeta() isn't given a type param; cast to the array branch we actually use.
|
||||||
const cellHadSource =
|
const cellHadSource =
|
||||||
(hot.getCellMeta(row, column).source || []).length < 1
|
((hot.getCellMeta(row, column).source as unknown[]) || []).length < 1
|
||||||
const cellHasValue = cellData !== ' '
|
const cellHasValue = cellData !== ' '
|
||||||
|
|
||||||
hot.batch(() => {
|
hot.batch(() => {
|
||||||
@@ -2260,7 +2348,9 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
) as string[]
|
) as string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const cellSourceCombined = [...new Set([...cellSource, ...colSource])]
|
const cellSourceCombined = [
|
||||||
|
...new Set([...cellSource, ...colSource])
|
||||||
|
] as string[]
|
||||||
|
|
||||||
hot.setCellMeta(row, column, 'source', cellSourceCombined)
|
hot.setCellMeta(row, column, 'source', cellSourceCombined)
|
||||||
|
|
||||||
@@ -2368,7 +2458,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const colSource = res.adapterResponse.dynamic_values.map(
|
const colSource = res.adapterResponse.dynamic_values.map(
|
||||||
(el: any) => el[this.cellValidationFields.RAW_VALUE]
|
(el: DynamicCellValidation) => el.RAW_VALUE
|
||||||
)
|
)
|
||||||
|
|
||||||
this.currentEditRecordLoadings.splice(
|
this.currentEditRecordLoadings.splice(
|
||||||
@@ -2596,6 +2686,12 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async ngOnInit() {
|
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
|
// Initialize hot table settings
|
||||||
this.updateHotTableSettings()
|
this.updateHotTableSettings()
|
||||||
|
|
||||||
@@ -2606,6 +2702,14 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// URL is the source of truth for the labels toggle: react to `?labels=`
|
||||||
|
// changes (including same-page navigation from the dropdown menu item)
|
||||||
|
// rather than reading it once from the route snapshot.
|
||||||
|
this._queryParams = this.route.queryParams.subscribe((params) => {
|
||||||
|
this.useLabels = parseLabelsParam(new URLSearchParams(params).toString())
|
||||||
|
this.applyDisplayColHeaders()
|
||||||
|
})
|
||||||
|
|
||||||
this._query = this.sasStoreService.query.subscribe((query: any) => {
|
this._query = this.sasStoreService.query.subscribe((query: any) => {
|
||||||
if (query.libds === this.libds) {
|
if (query.libds === this.libds) {
|
||||||
this.whereString = query.string
|
this.whereString = query.string
|
||||||
@@ -2640,6 +2744,12 @@ 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) {
|
if (this.libds) {
|
||||||
this.getdataError = false
|
this.getdataError = false
|
||||||
|
|
||||||
@@ -2655,6 +2765,51 @@ 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() {
|
ngAfterViewInit() {
|
||||||
// Fix ARIA accessibility issues after table initialization
|
// Fix ARIA accessibility issues after table initialization
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -2683,7 +2838,26 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}, 100)
|
}, 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() {
|
ngOnDestroy() {
|
||||||
|
this._queryParams?.unsubscribe()
|
||||||
|
|
||||||
// Clean up the MutationObserver
|
// Clean up the MutationObserver
|
||||||
if (this.ariaObserver) {
|
if (this.ariaObserver) {
|
||||||
this.ariaObserver.disconnect()
|
this.ariaObserver.disconnect()
|
||||||
@@ -2695,6 +2869,43 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
clearInterval(this.ariaCheckInterval)
|
clearInterval(this.ariaCheckInterval)
|
||||||
this.ariaCheckInterval = undefined
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2844,13 +3055,18 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
initSetup(response: EditorsGetDataServiceResponse) {
|
initSetup(response: EditorsGetDataServiceResponse, attempt = 0) {
|
||||||
this.hotInstance = this.hotTableComponent!.hotInstance!
|
|
||||||
|
|
||||||
if (this.getdataError) return
|
if (this.getdataError) return
|
||||||
if (!response) return
|
if (!response || !response.data) return
|
||||||
if (!response.data) return
|
|
||||||
if (!this.hotInstance) return
|
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
|
||||||
|
}
|
||||||
|
|
||||||
this.cols = response.data.cols
|
this.cols = response.data.cols
|
||||||
this.dsmeta = response.data.dsmeta
|
this.dsmeta = response.data.dsmeta
|
||||||
@@ -2959,7 +3175,11 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
hot.updateSettings(
|
hot.updateSettings(
|
||||||
{
|
{
|
||||||
data: this.dataSource,
|
data: this.dataSource,
|
||||||
colHeaders: this.headerColumns,
|
colHeaders: getDisplayColHeaders(
|
||||||
|
this.headerColumns,
|
||||||
|
this.cols,
|
||||||
|
this.useLabels
|
||||||
|
),
|
||||||
columns: this.cellValidation,
|
columns: this.cellValidation,
|
||||||
height: this.hotTable.height,
|
height: this.hotTable.height,
|
||||||
formulas: this.hotTable.formulas,
|
formulas: this.hotTable.formulas,
|
||||||
@@ -2983,7 +3203,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
manualColumnResize: true,
|
manualColumnResize: true,
|
||||||
filters: false,
|
filters: false,
|
||||||
manualRowResize: true,
|
manualRowResize: true,
|
||||||
viewportRowRenderingOffset: 50,
|
viewportRowRenderingOffset: 100,
|
||||||
// show a bar on the left to enable users to select an entire row
|
// show a bar on the left to enable users to select an entire row
|
||||||
rowHeaders: (index: number) => {
|
rowHeaders: (index: number) => {
|
||||||
return ' '
|
return ' '
|
||||||
@@ -3019,20 +3239,22 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
itemValue: string
|
itemValue: string
|
||||||
) => {
|
) => {
|
||||||
const elem = document.createElement('span')
|
const elem = document.createElement('span')
|
||||||
|
let colName = ''
|
||||||
let colInfo: DataFormat | undefined
|
let colInfo: DataFormat | undefined
|
||||||
let textInfo = 'No info found'
|
let textInfo = 'No info found'
|
||||||
|
|
||||||
if (this.hotInstance) {
|
if (this.hotInstance) {
|
||||||
const hotSelected: [number, number, number, number][] =
|
// getSelected() is typed number[][] in HOT 18 (was a 4-tuple
|
||||||
|
// array before); loosen the annotation to match.
|
||||||
|
const hotSelected: number[][] =
|
||||||
this.hotInstance.getSelected() || []
|
this.hotInstance.getSelected() || []
|
||||||
const selectedCol: number = hotSelected
|
const selectedCol: number = hotSelected
|
||||||
? hotSelected[0][1]
|
? hotSelected[0][1]
|
||||||
: -1
|
: -1
|
||||||
const colName = this.hotInstance?.colToProp(selectedCol)
|
colName = this.hotInstance?.colToProp(selectedCol) as string
|
||||||
colInfo = this.$dataFormats?.vars[colName]
|
colInfo = this.$dataFormats?.vars[colName]
|
||||||
|
|
||||||
if (colInfo)
|
textInfo = buildColInfoHtml(colName, colInfo)
|
||||||
textInfo = `LABEL: ${colInfo?.label}<br>TYPE: ${colInfo?.type}<br>LENGTH: ${colInfo?.length}<br>FORMAT: ${colInfo?.format}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
elem.innerHTML = textInfo
|
elem.innerHTML = textInfo
|
||||||
@@ -3270,8 +3492,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
)
|
)
|
||||||
|
|
||||||
hot.addHook('afterRender', (isForced: boolean) => {
|
hot.addHook('afterRender', (isForced: boolean) => {
|
||||||
this.eventService.dispatchEvent('resize')
|
|
||||||
|
|
||||||
// Fix ARIA accessibility issues after each render
|
// Fix ARIA accessibility issues after each render
|
||||||
this.fixAriaAccessibility()
|
this.fixAriaAccessibility()
|
||||||
})
|
})
|
||||||
@@ -3402,9 +3622,246 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
hot.render()
|
hot.render()
|
||||||
|
|
||||||
|
this.observeGridResize()
|
||||||
|
|
||||||
// Fix ARIA accessibility issues after table initialization
|
// Fix ARIA accessibility issues after table initialization
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.fixAriaAccessibility()
|
this.fixAriaAccessibility()
|
||||||
}, 500)
|
}, 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,6 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* Model for the dynamic cell validation in the editor
|
* Model for a row of `dynamic_values` returned by the getdynamiccolvals
|
||||||
* (sending whole row to the backend service and recieving data for the cell dropdown)
|
* 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).
|
||||||
*/
|
*/
|
||||||
export interface DynamicExtendedCellValidation {
|
export interface DynamicExtendedCellValidation {
|
||||||
DISPLAY_INDEX: number
|
DISPLAY_INDEX: number
|
||||||
@@ -8,5 +18,5 @@ export interface DynamicExtendedCellValidation {
|
|||||||
EXTRA_COL_NAME: string
|
EXTRA_COL_NAME: string
|
||||||
RAW_VALUE_CHAR: string
|
RAW_VALUE_CHAR: string
|
||||||
RAW_VALUE_NUM: number
|
RAW_VALUE_NUM: number
|
||||||
FORCE_FLAG: number
|
FORCED_VALUE: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,22 @@ export const dateToTime = (date: Date) => {
|
|||||||
return timeStr
|
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
|
* Converts date object to the YYYY-MM-DD
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
|
import {
|
||||||
|
DynamicCellValidation,
|
||||||
|
DynamicExtendedCellValidation
|
||||||
|
} from '../editor/models/dynamicExtendedCellValidation'
|
||||||
|
|
||||||
export interface CellValidationSource {
|
export interface CellValidationSource {
|
||||||
col: number
|
col: number
|
||||||
row: number
|
row: number
|
||||||
strict: boolean
|
strict: boolean
|
||||||
values: any[]
|
values: DynamicCellValidation[]
|
||||||
extended_values?: string[]
|
extended_values?: DynamicExtendedCellValidation[]
|
||||||
hash: string
|
hash: string
|
||||||
count: number
|
count: number
|
||||||
pending?: Promise<void>
|
pending?: Promise<void>
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ export interface MaxVarLength {
|
|||||||
|
|
||||||
export interface SASParam {
|
export interface SASParam {
|
||||||
COLHEADERS: string
|
COLHEADERS: string
|
||||||
COLTYPE: string
|
|
||||||
DTTMVARS: string
|
DTTMVARS: string
|
||||||
DTVARS: string
|
DTVARS: string
|
||||||
CLS_FLAG: number
|
CLS_FLAG: number
|
||||||
|
|||||||
@@ -22,10 +22,9 @@ import { ExcelRule } from '../models/TableData'
|
|||||||
import { HotTableInterface } from '../models/HotTable.interface'
|
import { HotTableInterface } from '../models/HotTable.interface'
|
||||||
import { Col } from '../shared/dc-validator/models/col.model'
|
import { Col } from '../shared/dc-validator/models/col.model'
|
||||||
import { SpreadsheetService } from '../services/spreadsheet.service'
|
import { SpreadsheetService } from '../services/spreadsheet.service'
|
||||||
import Handsontable from 'handsontable'
|
import Handsontable, { CellChange, ChangeSource } from 'handsontable'
|
||||||
import { HotTableComponent } from '@handsontable/angular-wrapper'
|
import { HotTableComponent } from '@handsontable/angular-wrapper'
|
||||||
import { EditorsStageDataSASResponse } from '../models/sas/editors-stagedata.model'
|
import { EditorsStageDataSASResponse } from '../models/sas/editors-stagedata.model'
|
||||||
import { CellChange, ChangeSource } from 'handsontable/common'
|
|
||||||
import { baseAfterGetColHeader } from '../shared/utils/hot.utils'
|
import { baseAfterGetColHeader } from '../shared/utils/hot.utils'
|
||||||
import { ColumnSettings } from 'handsontable/settings'
|
import { ColumnSettings } from 'handsontable/settings'
|
||||||
import { UploadFile } from '@sasjs/adapter'
|
import { UploadFile } from '@sasjs/adapter'
|
||||||
@@ -502,7 +501,9 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
|||||||
if (changes) {
|
if (changes) {
|
||||||
for (let change of changes) {
|
for (let change of changes) {
|
||||||
if (change && change[3]) {
|
if (change && change[3]) {
|
||||||
change[3] = change[3].toUpperCase()
|
// CellValue is `unknown` in HOT 18 (was a concrete union before);
|
||||||
|
// this column is always a dataset name string.
|
||||||
|
change[3] = (change[3] as string).toUpperCase()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -552,7 +553,9 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
|||||||
dynamicCellValidations() {
|
dynamicCellValidations() {
|
||||||
if (!this.hotInstanceUserDataset) return
|
if (!this.hotInstanceUserDataset) return
|
||||||
|
|
||||||
const hotData = this.hotInstanceUserDataset.getData()
|
// getData() returns unknown[][] in HOT 18 (was CellValue[][]); this grid's
|
||||||
|
// data is always library/table name strings.
|
||||||
|
const hotData = this.hotInstanceUserDataset.getData() as string[][]
|
||||||
|
|
||||||
hotData.forEach((row, rowIndex) => {
|
hotData.forEach((row, rowIndex) => {
|
||||||
const library = row[0]
|
const library = row[0]
|
||||||
@@ -573,10 +576,12 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
|||||||
|
|
||||||
if (dataAtRow && dataAtRow[0] && dataAtRow[1]) {
|
if (dataAtRow && dataAtRow[0] && dataAtRow[1]) {
|
||||||
if (!this.matchedDatasets.includes(dataset)) {
|
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) => {
|
cellMetaAtRow.forEach((cellMeta) => {
|
||||||
this.hotInstanceUserDataset.setCellMeta(
|
this.hotInstanceUserDataset.setCellMeta(
|
||||||
row,
|
row,
|
||||||
cellMeta.col,
|
cellMeta.col as number,
|
||||||
'className',
|
'className',
|
||||||
'not-matched'
|
'not-matched'
|
||||||
)
|
)
|
||||||
@@ -585,7 +590,7 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
|||||||
cellMetaAtRow.forEach((cellMeta) => {
|
cellMetaAtRow.forEach((cellMeta) => {
|
||||||
this.hotInstanceUserDataset.setCellMeta(
|
this.hotInstanceUserDataset.setCellMeta(
|
||||||
row,
|
row,
|
||||||
cellMeta.col,
|
cellMeta.col as number,
|
||||||
'className',
|
'className',
|
||||||
''
|
''
|
||||||
)
|
)
|
||||||
@@ -595,7 +600,7 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
|||||||
cellMetaAtRow.forEach((cellMeta) => {
|
cellMetaAtRow.forEach((cellMeta) => {
|
||||||
this.hotInstanceUserDataset.setCellMeta(
|
this.hotInstanceUserDataset.setCellMeta(
|
||||||
row,
|
row,
|
||||||
cellMeta.col,
|
cellMeta.col as number,
|
||||||
'className',
|
'className',
|
||||||
''
|
''
|
||||||
)
|
)
|
||||||
@@ -991,7 +996,8 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
|||||||
private getDatasetsFromHot(): string[] {
|
private getDatasetsFromHot(): string[] {
|
||||||
if (!this.hotInstanceUserDataset) return []
|
if (!this.hotInstanceUserDataset) return []
|
||||||
|
|
||||||
const hotData = this.hotInstanceUserDataset.getData()
|
// getData() returns unknown[][] in HOT 18; see dynamicCellValidations() above.
|
||||||
|
const hotData = this.hotInstanceUserDataset.getData() as string[][]
|
||||||
|
|
||||||
return hotData
|
return hotData
|
||||||
.filter((row) => row[0]?.length && row[1]?.length)
|
.filter((row) => row[0]?.length && row[1]?.length)
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
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))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +1,26 @@
|
|||||||
import Handsontable from 'handsontable'
|
import Handsontable from 'handsontable'
|
||||||
import { datetimeValidator } from '../validations/hot-custom-validators'
|
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.
|
* Cell types for the date/time/datetime columns after the HOT 17 migration.
|
||||||
*
|
*
|
||||||
* `date` and `time` use Handsontable's BUILT-IN `intl-date` / `intl-time` cell
|
* All three DISPLAY the raw stored ISO string verbatim — `YYYY-MM-DD`,
|
||||||
* types as-is — a native (browser/OS) picker editor plus the Intl renderer that
|
* `HH:mm:ss`, `YYYY-MM-DD HH:mm:ss` — via an identity `valueFormatter`, so the
|
||||||
* formats the displayed value per the cell's `locale` + `dateFormat`/`timeFormat`
|
* cell text is stable and locale-independent. The native picker editors still
|
||||||
* (set as cell meta in mapIntlCellTypes). They are intentionally NOT re-registered
|
* follow the OS regional format while open; only the painted cell is ISO.
|
||||||
* here, so their localizing `valueFormatter` stays active. The stored/submitted
|
* The stored/submitted value is unchanged.
|
||||||
* value remains the raw ISO string; only the display is localized.
|
|
||||||
*
|
*
|
||||||
* Only `intl-datetime` needs registering — HOT has no built-in datetime type. It
|
* `intl-date` and `intl-time` are HOT built-ins; we re-register them by spreading
|
||||||
* pairs a native `datetime-local` picker with a `valueFormatter` that formats the
|
* the built-in definition (keeping its native picker editor + ISO validator) and
|
||||||
* stored `YYYY-MM-DD HH:mm:ss` per the cell's locale (mirroring how the built-in
|
* swapping only the `valueFormatter` for the identity one. `intl-datetime` has no
|
||||||
* intl-date/intl-time types localize, via tableView's `renderer.valueFormatter`).
|
* 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.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** 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 SPACE_ISO = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}(?::\d{2})?)$/
|
||||||
|
|
||||||
const BaseTextEditor = Handsontable.editors.getEditor('text') as any
|
const BaseTextEditor = Handsontable.editors.getEditor('text') as any
|
||||||
@@ -63,39 +66,34 @@ class IntlDatetimeEditor extends BaseTextEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Localizes a stored `YYYY-MM-DD HH:mm:ss` datetime per the cell's locale.
|
* Re-registers a built-in HOT cell type (`intl-date` / `intl-time`) with only its
|
||||||
* Invoked by Handsontable via `renderer.valueFormatter` (tableView) before the
|
* `valueFormatter` swapped for the ISO passthrough — the native picker editor and
|
||||||
* text renderer paints the cell, so the stored value is never mutated. Falls
|
* ISO validator are reused from the built-in definition. Idempotent: a second
|
||||||
* back to the raw value when it is empty or unparseable.
|
* call reads back our already-overridden type, which still carries the original
|
||||||
|
* editor/validator.
|
||||||
*/
|
*/
|
||||||
function intlDatetimeValueFormatter(value: any, cellProperties: any): any {
|
const overrideWithPassthrough = (type: string) => {
|
||||||
if (typeof value !== 'string') return value
|
const builtin = Handsontable.cellTypes.getCellType(type) as any
|
||||||
|
Handsontable.cellTypes.registerCellType(type, {
|
||||||
const match = value.match(SPACE_ISO)
|
...builtin,
|
||||||
if (!match) return value
|
valueFormatter: isoPassthrough
|
||||||
|
})
|
||||||
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-datetime` cell type. Idempotent — safe to call per
|
* Registers the Intl date/time/datetime cell types. Idempotent — safe to call
|
||||||
* DcValidator construction. `intl-date`/`intl-time` are not registered here;
|
* per DcValidator construction. All three display the stored ISO value verbatim.
|
||||||
* the built-in HOT types are used directly.
|
|
||||||
*/
|
*/
|
||||||
export const registerIntlCellTypes = () => {
|
export const registerIntlCellTypes = () => {
|
||||||
|
overrideWithPassthrough('intl-date')
|
||||||
|
overrideWithPassthrough('intl-time')
|
||||||
|
|
||||||
Handsontable.cellTypes.registerCellType('intl-datetime', {
|
Handsontable.cellTypes.registerCellType('intl-datetime', {
|
||||||
// IntlDatetimeEditor extends the (loosely-typed) text editor returned by
|
// IntlDatetimeEditor extends the (loosely-typed) text editor returned by
|
||||||
// getEditor('text'); cast to satisfy registerCellType's editor signature.
|
// getEditor('text'); cast to satisfy registerCellType's editor signature.
|
||||||
editor: IntlDatetimeEditor as any,
|
editor: IntlDatetimeEditor as any,
|
||||||
renderer: baseTextRenderer,
|
renderer: baseTextRenderer,
|
||||||
validator: datetimeValidator,
|
validator: datetimeValidator,
|
||||||
valueFormatter: intlDatetimeValueFormatter
|
valueFormatter: isoPassthrough
|
||||||
} as any)
|
} as any)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import { DQRule, DQRuleTypes } from './models/dq-rules.model'
|
|||||||
import { getDqDataCols } from './utils/getDqDataCols'
|
import { getDqDataCols } from './utils/getDqDataCols'
|
||||||
import { getNotNullDefault } from './utils/getNotNullDefault'
|
import { getNotNullDefault } from './utils/getNotNullDefault'
|
||||||
import { mergeColsRules } from './utils/mergeColsRules'
|
import { mergeColsRules } from './utils/mergeColsRules'
|
||||||
import { parseColType } from './utils/parseColType'
|
import { parseColTypeRow } from './utils/parseColTypeRow'
|
||||||
|
import { DELETE_RECORD_COLUMN_RULE } from './utils/deleteRecordColumnRule'
|
||||||
import { dqValidate } from './validations/dq-validation'
|
import { dqValidate } from './validations/dq-validation'
|
||||||
import {
|
import {
|
||||||
datetimeValidator,
|
datetimeValidator,
|
||||||
@@ -50,7 +51,33 @@ export class DcValidator {
|
|||||||
|
|
||||||
this.sasparams = sasparams
|
this.sasparams = sasparams
|
||||||
this.hotInstance = hotInstance
|
this.hotInstance = hotInstance
|
||||||
this.rules = parseColType(sasparams.COLTYPE)
|
// Each cols[i].COLTYPE is a single JSON-object string (one per column, as
|
||||||
|
// of the vertical-array getdata.sas fix — issue #253), not the old
|
||||||
|
// comma-joined sasparams.COLTYPE list. DELETE_RECORD_COLUMN_RULE is
|
||||||
|
// injected separately — see its own doc comment for why.
|
||||||
|
//
|
||||||
|
// COLHEADERS is the authoritative grid order and the only safe key to
|
||||||
|
// build against: it is the variable list of the *output* dataset, where
|
||||||
|
// getdata.sas hoists the delete column and the PK to the front ("order
|
||||||
|
// delete var and pk fields at start of table"), whereas cols[].VARNUM
|
||||||
|
// describes the *source* table and cols[] itself can arrive sorted by
|
||||||
|
// NAME. Those orders diverge whenever the PK isn't already the source's
|
||||||
|
// first column, so rules are keyed by name and emitted in COLHEADERS
|
||||||
|
// order — never paired positionally against cols[] or VARNUM.
|
||||||
|
//
|
||||||
|
// A column with no parseable COLTYPE falls back to a plain text rule
|
||||||
|
// rather than being dropped: getRules() is consumed positionally against
|
||||||
|
// COLHEADERS, so a missing entry would shift every later column.
|
||||||
|
const ruleByName = new Map<string, DcValidation>([
|
||||||
|
[DELETE_RECORD_COLUMN_RULE.data, { ...DELETE_RECORD_COLUMN_RULE }]
|
||||||
|
])
|
||||||
|
for (const col of cols) {
|
||||||
|
const rule = parseColTypeRow(col.COLTYPE)
|
||||||
|
if (rule) ruleByName.set(col.NAME, rule)
|
||||||
|
}
|
||||||
|
this.rules = sasparams.COLHEADERS.split(',').map(
|
||||||
|
(name) => ruleByName.get(name) ?? { data: name }
|
||||||
|
)
|
||||||
this.rules = mergeColsRules(cols, this.rules, $dataFormats)
|
this.rules = mergeColsRules(cols, this.rules, $dataFormats)
|
||||||
this.rules = applyNumericFormats(this.rules)
|
this.rules = applyNumericFormats(this.rules)
|
||||||
this.rules = mapIntlCellTypes(this.rules)
|
this.rules = mapIntlCellTypes(this.rules)
|
||||||
@@ -540,8 +567,11 @@ export class DcValidator {
|
|||||||
|
|
||||||
if (rule.clsRule === 'EDIT') {
|
if (rule.clsRule === 'EDIT') {
|
||||||
rule.readOnly = false
|
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)
|
const hiddenIndex = this.hiddenColumns.indexOf(index)
|
||||||
this.hiddenColumns.splice(hiddenIndex, 1)
|
if (hiddenIndex !== -1) this.hiddenColumns.splice(hiddenIndex, 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import Handsontable from 'handsontable'
|
import Handsontable from 'handsontable'
|
||||||
import Core from 'handsontable/core'
|
|
||||||
|
|
||||||
export class CustomAutocompleteEditor
|
export class CustomAutocompleteEditor
|
||||||
extends Handsontable.editors.AutocompleteEditor
|
extends Handsontable.editors.AutocompleteEditor
|
||||||
{
|
{
|
||||||
constructor(instance: Core) {
|
constructor(instance: Handsontable.Core) {
|
||||||
super(instance)
|
super(instance)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,8 +12,9 @@ export class CustomAutocompleteEditor
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Listbox open
|
// Listbox open
|
||||||
open(event?: Event | undefined): void {
|
// HOT 18's AutocompleteEditor.open() takes no arguments (was `event?: Event`).
|
||||||
super.open(event)
|
open(): void {
|
||||||
|
super.open()
|
||||||
|
|
||||||
if (this.isCellNumeric()) {
|
if (this.isCellNumeric()) {
|
||||||
this.htContainer.classList.add('numericListbox')
|
this.htContainer.classList.add('numericListbox')
|
||||||
|
|||||||
@@ -9,4 +9,8 @@ export interface Col {
|
|||||||
MEMLABEL: string
|
MEMLABEL: string
|
||||||
DESC: string
|
DESC: string
|
||||||
LONGDESC: 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ describe('DC Validator', () => {
|
|||||||
// Check if COLS merged with FORMATS
|
// Check if COLS merged with FORMATS
|
||||||
expect(cols[0].TYPE).toEqual('char')
|
expect(cols[0].TYPE).toEqual('char')
|
||||||
|
|
||||||
// Get all
|
// Get all — one rule per cols[] entry, plus the injected
|
||||||
|
// DELETE_RECORD_COLUMN_RULE (never present in cols[], see its own
|
||||||
|
// doc comment)
|
||||||
const validationRules = dcValidator.getRules()
|
const validationRules = dcValidator.getRules()
|
||||||
expect(validationRules).toHaveSize(
|
expect(validationRules).toHaveSize(example_cols.length + 1)
|
||||||
JSON.parse(`[${example_COLTYPE}]`).length
|
|
||||||
)
|
|
||||||
|
|
||||||
// Get col with notnull validation
|
// Get col with notnull validation
|
||||||
const someNumRule = dcValidator.getRule('SOME_NUM')
|
const someNumRule = dcValidator.getRule('SOME_NUM')
|
||||||
@@ -35,26 +35,21 @@ describe('DC Validator', () => {
|
|||||||
expect(someNumRule?.allowEmpty).toBeFalse()
|
expect(someNumRule?.allowEmpty).toBeFalse()
|
||||||
|
|
||||||
// SAS sends semantic date/time/datetime column types; the frontend maps them
|
// SAS sends semantic date/time/datetime column types; the frontend maps them
|
||||||
// to the HOT 17 Intl cell types and attaches locale + Intl format meta so the
|
// to the HOT 17 Intl cell-type names (see mapIntlCellTypes). The cell display
|
||||||
// cell display is localized (see mapIntlCellTypes). The deprecated
|
// is the raw stored ISO string (no locale/format meta) — see
|
||||||
// correctFormat + moment format strings are no longer sent in the payload.
|
// registerIntlCellTypes. The deprecated correctFormat + moment format strings
|
||||||
|
// are no longer sent in the payload.
|
||||||
const someDateRule = dcValidator.getRule('SOME_DATE')
|
const someDateRule = dcValidator.getRule('SOME_DATE')
|
||||||
expect(someDateRule?.correctFormat).toBeUndefined()
|
expect(someDateRule?.correctFormat).toBeUndefined()
|
||||||
expect(someDateRule?.type).toBe('intl-date')
|
expect(someDateRule?.type).toBe('intl-date')
|
||||||
expect(someDateRule?.dateFormat).toBeDefined()
|
|
||||||
expect((someDateRule as any)?.locale).toBeTruthy()
|
|
||||||
|
|
||||||
const someDatetimeRule = dcValidator.getRule('SOME_DATETIME')
|
const someDatetimeRule = dcValidator.getRule('SOME_DATETIME')
|
||||||
expect(someDatetimeRule?.type).toBe('intl-datetime')
|
expect(someDatetimeRule?.type).toBe('intl-datetime')
|
||||||
expect(someDatetimeRule?.correctFormat).toBeUndefined()
|
expect(someDatetimeRule?.correctFormat).toBeUndefined()
|
||||||
expect((someDatetimeRule as any)?.datetimeFormat).toBeDefined()
|
|
||||||
expect((someDatetimeRule as any)?.locale).toBeTruthy()
|
|
||||||
|
|
||||||
const someTimeRule = dcValidator.getRule('SOME_TIME')
|
const someTimeRule = dcValidator.getRule('SOME_TIME')
|
||||||
expect(someTimeRule?.type).toBe('intl-time')
|
expect(someTimeRule?.type).toBe('intl-time')
|
||||||
expect(someTimeRule?.correctFormat).toBeUndefined()
|
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
|
// Get col with dropdown, check if merging sas data is correct
|
||||||
const someDropdownRule = dcValidator.getRule('SOME_DROPDOWN')
|
const someDropdownRule = dcValidator.getRule('SOME_DROPDOWN')
|
||||||
@@ -71,6 +66,15 @@ describe('DC Validator', () => {
|
|||||||
const someDropdownHardRule = dcValidator.getRule('SOME_DROPDOWN_HARD')
|
const someDropdownHardRule = dcValidator.getRule('SOME_DROPDOWN_HARD')
|
||||||
// Check strict - it is hardselect so strict should be true
|
// Check strict - it is hardselect so strict should be true
|
||||||
expect(someDropdownHardRule?.strict).toBeTrue()
|
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', () => {
|
it('should create an instance of validator and execute its functions', () => {
|
||||||
@@ -283,8 +287,151 @@ describe('DC Validator', () => {
|
|||||||
expect(dcValidator.getRoundDigits('SOME_SHORTNUM')).toEqual(2)
|
expect(dcValidator.getRoundDigits('SOME_SHORTNUM')).toEqual(2)
|
||||||
expect(dcValidator.getRoundDigits('SOME_NUM')).toBeUndefined()
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 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 = [
|
const example_dqData = [
|
||||||
{
|
{
|
||||||
BASE_COL: 'SOME_DROPDOWN',
|
BASE_COL: 'SOME_DROPDOWN',
|
||||||
@@ -388,6 +535,13 @@ 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 = [
|
const example_cols = [
|
||||||
{
|
{
|
||||||
CLS_RULE: 'READ',
|
CLS_RULE: 'READ',
|
||||||
@@ -399,7 +553,8 @@ const example_cols = [
|
|||||||
LONGDESC: '',
|
LONGDESC: '',
|
||||||
MEMLABEL: '',
|
MEMLABEL: '',
|
||||||
NAME: 'SOME_DROPDOWN',
|
NAME: 'SOME_DROPDOWN',
|
||||||
VARNUM: 3
|
VARNUM: 3,
|
||||||
|
COLTYPE: '{"data":"SOME_DROPDOWN"}'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
CLS_RULE: 'READ',
|
CLS_RULE: 'READ',
|
||||||
@@ -411,77 +566,157 @@ const example_cols = [
|
|||||||
LONGDESC: '',
|
LONGDESC: '',
|
||||||
MEMLABEL: '',
|
MEMLABEL: '',
|
||||||
NAME: 'SOME_NUM',
|
NAME: 'SOME_NUM',
|
||||||
VARNUM: 4
|
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"}'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
const example_COLTYPE = `
|
// 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
|
||||||
"data":"_____DELETE__THIS__RECORD_____",
|
// remaining source columns in VARNUM order. It drives rule order, so it must
|
||||||
"type":"dropdown",
|
// list every example_cols entry.
|
||||||
"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 = {
|
const example_sasparams = {
|
||||||
CLS_FLAG: 0,
|
CLS_FLAG: 0,
|
||||||
COLHEADERS: 'head1 head2',
|
COLHEADERS:
|
||||||
COLTYPE: example_COLTYPE,
|
'_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,SOME_CHAR,SOME_DROPDOWN,' +
|
||||||
|
'SOME_NUM,SOME_CHAR_LOW,SOME_CHAR_ANY,SOME_DROPDOWN_HARD,SOME_DATE,' +
|
||||||
|
'SOME_DATETIME,SOME_TIME,SOME_SHORTNUM,SOME_BESTNUM',
|
||||||
DTTMVARS: 'dttm vars',
|
DTTMVARS: 'dttm vars',
|
||||||
DTVARS: 'dt vars',
|
DTVARS: 'dt vars',
|
||||||
FILTER_TEXT: 'filter text',
|
FILTER_TEXT: 'filter text',
|
||||||
LOADTYPE: 'load type',
|
LOADTYPE: 'load type',
|
||||||
PK: 'primary key',
|
PK: 'PRIMARY_KEY_FIELD',
|
||||||
PKCNT: 0,
|
PKCNT: 1,
|
||||||
RK_FLAG: 0,
|
RK_FLAG: 0,
|
||||||
TMVARS: 'string'
|
TMVARS: 'string'
|
||||||
}
|
}
|
||||||
@@ -512,6 +747,24 @@ const example_dataformats = {
|
|||||||
length: '128',
|
length: '128',
|
||||||
type: 'char'
|
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: {
|
SOME_NUM: {
|
||||||
format: 'best.',
|
format: 'best.',
|
||||||
label: 'SOME_NUM',
|
label: 'SOME_NUM',
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import { HotColumnSettings } from '../models/dc-validation.model'
|
|
||||||
import { parseColType } from '../utils/parseColType'
|
|
||||||
|
|
||||||
describe('DC Validator - parse col type', () => {
|
|
||||||
it('should return array of parsed json', () => {
|
|
||||||
const colTypeString =
|
|
||||||
'{"data":"test","test2":"test2"}, {"data":"test3","test4":"test4"}'
|
|
||||||
const expected: HotColumnSettings[] = [
|
|
||||||
{ data: 'test', test2: 'test2' },
|
|
||||||
{ data: 'test3', test4: 'test4' }
|
|
||||||
]
|
|
||||||
|
|
||||||
expect(parseColType(colTypeString)).toEqual(expected)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return empty array for invalid json', () => {
|
|
||||||
const colTypeString =
|
|
||||||
'{"test":"test""test:"test2"}, {"test3":"test3","test4":"test4"}'
|
|
||||||
const expected: HotColumnSettings[] = []
|
|
||||||
|
|
||||||
expect(parseColType(colTypeString)).toEqual(expected)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { HotColumnSettings } from '../models/dc-validation.model'
|
||||||
|
import { parseColTypeRow } from '../utils/parseColTypeRow'
|
||||||
|
|
||||||
|
describe('DC Validator - parse col type row', () => {
|
||||||
|
it('parses a single JSON object', () => {
|
||||||
|
const expected: HotColumnSettings = { data: 'test', type: 'numeric' }
|
||||||
|
|
||||||
|
expect(parseColTypeRow('{"data":"test","type":"numeric"}')).toEqual(
|
||||||
|
expected
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns undefined for undefined input (LEFT JOIN can leave it unset)', () => {
|
||||||
|
expect(parseColTypeRow(undefined)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns undefined for an empty string', () => {
|
||||||
|
expect(parseColTypeRow('')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns undefined for malformed JSON, without throwing', () => {
|
||||||
|
expect(parseColTypeRow('{not valid')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { DcValidation } from '../models/dc-validation.model'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `_____DELETE__THIS__RECORD_____` is a client-side UI concept (the delete
|
||||||
|
* checkbox column) that getdata.sas synthesizes into vars3/vars4 for display
|
||||||
|
* purposes only — it's never a real column of the underlying dataset, so
|
||||||
|
* `%mp_getcols` (which builds `cols1`, the left side of the join into
|
||||||
|
* `cols`) never has a row for it, and its rule can no longer travel via
|
||||||
|
* `cols[].COLTYPE` (see issue #253's vertical-array fix). The rule is fixed
|
||||||
|
* and data-independent, so it's injected directly here instead.
|
||||||
|
*/
|
||||||
|
export const DELETE_RECORD_COLUMN_RULE: DcValidation = {
|
||||||
|
data: '_____DELETE__THIS__RECORD_____',
|
||||||
|
type: 'dropdown',
|
||||||
|
source: ['No', 'Yes']
|
||||||
|
}
|
||||||
@@ -2,58 +2,31 @@ import { DcValidation } from '../models/dc-validation.model'
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps SAS' semantic date/time/datetime column types to the HOT 17 Intl cell
|
* Maps SAS' semantic date/time/datetime column types to the HOT 17 Intl cell
|
||||||
* types and attaches the locale + Intl format meta that drive their display.
|
* type names. SAS sends a HOT-agnostic flag (`date`/`time`/`datetime`); the
|
||||||
|
* frontend owns the HOT-specific cell-type names:
|
||||||
*
|
*
|
||||||
* SAS sends a HOT-agnostic flag (`date`/`time`/`datetime`); the frontend owns
|
* - `date` → `intl-date` (native date picker)
|
||||||
* the HOT-specific cell-type names and the locale-aware rendering:
|
* - `time` → `intl-time` (native time picker)
|
||||||
|
* - `datetime` → `intl-datetime` (native datetime-local picker)
|
||||||
*
|
*
|
||||||
* - `date` → `intl-date` (native date picker; display via Intl `dateFormat`)
|
* The cell display is the raw stored ISO string (locale-independent) — see
|
||||||
* - `time` → `intl-time` (native time picker; display via Intl `timeFormat`)
|
* registerIntlCellTypes, where these types' `valueFormatter` is the identity
|
||||||
* - `datetime` → `intl-datetime` (native datetime-local picker; display via `datetimeFormat`)
|
* 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
|
||||||
* The cell display is localized to `navigator.language` (the browser/OS locale);
|
* constructor, before the rules reach Handsontable.
|
||||||
* 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`.
|
* @param rules Cell validation rules passed to Handsontable's `columns`.
|
||||||
*/
|
*/
|
||||||
const DATE_FORMAT: Intl.DateTimeFormatOptions = {
|
const INTL_TYPE_MAP: Record<string, string> = {
|
||||||
year: 'numeric',
|
date: 'intl-date',
|
||||||
month: '2-digit',
|
time: 'intl-time',
|
||||||
day: '2-digit'
|
datetime: 'intl-datetime'
|
||||||
}
|
|
||||||
|
|
||||||
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[] => {
|
export const mapIntlCellTypes = (rules: DcValidation[]): DcValidation[] => {
|
||||||
for (const rule of rules) {
|
for (const rule of rules) {
|
||||||
const cfg = rule.type ? INTL_TYPE_MAP[rule.type as string] : undefined
|
const mapped = rule.type ? INTL_TYPE_MAP[rule.type as string] : undefined
|
||||||
if (!cfg) continue
|
if (mapped) rule.type = mapped
|
||||||
|
|
||||||
rule.type = cfg.type
|
|
||||||
const meta = rule as Record<string, unknown>
|
|
||||||
meta.locale = window.navigator.language
|
|
||||||
Object.assign(meta, cfg.meta)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return rules
|
return rules
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export const mergeColsRules = (
|
|||||||
: colFormats.type
|
: colFormats.type
|
||||||
|
|
||||||
if (rule && col.DESC) rule.desc = col.DESC
|
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 && col.CLS_RULE) rule.clsRule = col.CLS_RULE
|
||||||
if (rule && colFormats?.type) rule.sasType = colFormats.type
|
if (rule && colFormats?.type) rule.sasType = colFormats.type
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import { HotColumnSettings } from '../models/dc-validation.model'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* From sas we get a string instead of array of objects, in that string `[]` are missing so
|
|
||||||
* before parsing JSON we need to add them.
|
|
||||||
*
|
|
||||||
* @param coltype string (objects) that comes from sas
|
|
||||||
* @returns JSON Handsontable.ColumnSettings[]
|
|
||||||
*/
|
|
||||||
export const parseColType = (coltype: string): HotColumnSettings[] => {
|
|
||||||
try {
|
|
||||||
return JSON.parse(`[${coltype}]`)
|
|
||||||
} catch (err: any) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { HotColumnSettings } from '../models/dc-validation.model'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a single column's `COLTYPE` JSON-object string (one per `cols` row,
|
||||||
|
* as of the vertical-array getdata.sas fix — see issue #253). Unlike the
|
||||||
|
* previous comma-joined `sasparams.COLTYPE` format, each row is independent:
|
||||||
|
* a malformed or missing value here only drops that one column's rule,
|
||||||
|
* rather than every column's.
|
||||||
|
*
|
||||||
|
* @param coltype a single column's COLTYPE JSON string, or undefined (the
|
||||||
|
* LEFT JOIN in getdata.sas can leave it unset for unmatched rows)
|
||||||
|
*/
|
||||||
|
export const parseColTypeRow = (
|
||||||
|
coltype: string | undefined
|
||||||
|
): HotColumnSettings | undefined => {
|
||||||
|
if (!coltype) return undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(coltype)
|
||||||
|
} catch (err: any) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,12 +52,14 @@ export async function exportGrid(
|
|||||||
// Mirror HOT's own export item: only honor a selection that spans more than
|
// Mirror HOT's own export item: only honor a selection that spans more than
|
||||||
// one cell. A right-click places a single-cell cursor, and the corner click
|
// one cell. A right-click places a single-cell cursor, and the corner click
|
||||||
// is select-all (negative coords) — both mean "export the whole table".
|
// is select-all (negative coords) — both mean "export the whole table".
|
||||||
const isCornerSelectAll = !!sel && sel.from.row < 0 && sel.from.col < 0
|
// sel.from/to.row/col are typed nullable (CellCoords allows an unset state),
|
||||||
|
// but a range returned by getSelectedRangeLast() always has real coordinates.
|
||||||
|
const isCornerSelectAll = !!sel && sel.from.row! < 0 && sel.from.col! < 0
|
||||||
if (sel && !sel.isSingleCell() && !isCornerSelectAll) {
|
if (sel && !sel.isSingleCell() && !isCornerSelectAll) {
|
||||||
const top = Math.max(0, Math.min(sel.from.row, sel.to.row))
|
const top = Math.max(0, Math.min(sel.from.row!, sel.to.row!))
|
||||||
const left = Math.max(0, Math.min(sel.from.col, sel.to.col))
|
const left = Math.max(0, Math.min(sel.from.col!, sel.to.col!))
|
||||||
const bottom = Math.max(sel.from.row, sel.to.row)
|
const bottom = Math.max(sel.from.row!, sel.to.row!)
|
||||||
const right = Math.max(sel.from.col, sel.to.col)
|
const right = Math.max(sel.from.col!, sel.to.col!)
|
||||||
opts['range'] = [top, Math.max(left, skipLeadingCols), bottom, right]
|
opts['range'] = [top, Math.max(left, skipLeadingCols), bottom, right]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { BehaviorSubject } from 'rxjs'
|
||||||
|
import * as XLSX from '@sheet/crypto'
|
||||||
|
import { SpreadsheetUtil } from './spreadsheet-util'
|
||||||
|
import { LicenceState } from 'src/app/models/LicenceState'
|
||||||
|
import { ParseParams } from 'src/app/models/ParseParams.interface'
|
||||||
|
import { SearchDataExcelResult } from 'src/app/models/SearchDataExcelResult.interface'
|
||||||
|
|
||||||
|
describe('SpreadsheetUtil - header matching', () => {
|
||||||
|
// licenceState is unused by searchDataInExcel, so an empty stub is fine.
|
||||||
|
const buildSpreadsheetUtil = () =>
|
||||||
|
new SpreadsheetUtil({
|
||||||
|
licenceState: new BehaviorSubject<LicenceState>({} as LicenceState)
|
||||||
|
})
|
||||||
|
|
||||||
|
const buildWorkbook = (headerRow: string[], dataRow: any[]) => {
|
||||||
|
const ws = XLSX.utils.aoa_to_sheet([headerRow, dataRow])
|
||||||
|
const wb = XLSX.utils.book_new()
|
||||||
|
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, 'Sheet1')
|
||||||
|
|
||||||
|
return wb
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchDataInExcel only reads headerArray/headerPks off ParseParams; the
|
||||||
|
// other required fields (dcValidator, etc.) are irrelevant here, so a
|
||||||
|
// minimal cast object stands in for a full ParseParams.
|
||||||
|
const buildParseParams = (): ParseParams =>
|
||||||
|
({
|
||||||
|
headerArray: ['PRIMARY_KEY_FIELD', 'SOME_CHAR', 'SOME_NUM'],
|
||||||
|
headerPks: ['PRIMARY_KEY_FIELD']
|
||||||
|
}) as any as ParseParams
|
||||||
|
|
||||||
|
// searchDataInExcel is private; there's no existing precedent in this
|
||||||
|
// codebase for testing private methods, so this casts through `any` to
|
||||||
|
// reach it directly rather than exercising it via the public
|
||||||
|
// parseSpreadsheetFile (which would need a real File/FileReader).
|
||||||
|
const searchDataInExcel = (
|
||||||
|
spreadsheetUtil: SpreadsheetUtil,
|
||||||
|
wb: XLSX.WorkBook,
|
||||||
|
parseParams: ParseParams
|
||||||
|
): SearchDataExcelResult =>
|
||||||
|
(spreadsheetUtil as any).searchDataInExcel(wb, parseParams)
|
||||||
|
|
||||||
|
it('matches headers with leading whitespace against the expected clean header names', () => {
|
||||||
|
const wb = buildWorkbook(
|
||||||
|
['PRIMARY_KEY_FIELD', ' SOME_CHAR', ' SOME_NUM'],
|
||||||
|
[1, 'a value', 42]
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = searchDataInExcel(
|
||||||
|
buildSpreadsheetUtil(),
|
||||||
|
wb,
|
||||||
|
buildParseParams()
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.missing).toBeUndefined()
|
||||||
|
expect(result.found).toBeDefined()
|
||||||
|
expect(result.found?.headers).toContain('some_char')
|
||||||
|
expect(result.found?.headers).toContain('some_num')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still reports a genuinely different header as missing (no over-broadening)', () => {
|
||||||
|
const wb = buildWorkbook(
|
||||||
|
['PRIMARY_KEY_FIELD', ' SOME_CHARX', ' SOME_NUM'],
|
||||||
|
[1, 'a value', 42]
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = searchDataInExcel(
|
||||||
|
buildSpreadsheetUtil(),
|
||||||
|
wb,
|
||||||
|
buildParseParams()
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.found).toBeUndefined()
|
||||||
|
expect(result.missing).toBeDefined()
|
||||||
|
expect(result.missing?.[0].missingHeaders).toContain('SOME_CHAR')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('SpreadsheetUtil - updateDateTimeCols time normalization', () => {
|
||||||
|
const buildSpreadsheetUtil = () =>
|
||||||
|
new SpreadsheetUtil({
|
||||||
|
licenceState: new BehaviorSubject<LicenceState>({} as LicenceState)
|
||||||
|
})
|
||||||
|
|
||||||
|
// updateDateTimeCols is private; cast through `any` (same precedent as above).
|
||||||
|
const updateDateTimeCols = (
|
||||||
|
spreadsheetUtil: SpreadsheetUtil,
|
||||||
|
headers: string[],
|
||||||
|
data: any[],
|
||||||
|
parseParams: ParseParams
|
||||||
|
): any[] =>
|
||||||
|
(spreadsheetUtil as any).updateDateTimeCols(headers, data, parseParams)
|
||||||
|
|
||||||
|
const timeParseParams = (): ParseParams =>
|
||||||
|
({
|
||||||
|
dateHeaders: [],
|
||||||
|
dateTimeHeaders: [],
|
||||||
|
timeHeaders: ['SOME_TIME']
|
||||||
|
}) as any as ParseParams
|
||||||
|
|
||||||
|
it('pads unpadded-hour time strings to canonical HH:mm:ss and leaves canonical values alone', () => {
|
||||||
|
// The core bug: SAS TIME8. / Excel render a single-digit hour (`0:00:42`),
|
||||||
|
// arriving as a plain string (not an Excel serial). Already-canonical values
|
||||||
|
// must pass through untouched.
|
||||||
|
const data = [[{ v: '0:00:42' }], [{ v: '9:05:03' }], [{ v: '23:00:00' }]]
|
||||||
|
|
||||||
|
const result = updateDateTimeCols(
|
||||||
|
buildSpreadsheetUtil(),
|
||||||
|
['SOME_TIME'],
|
||||||
|
data,
|
||||||
|
timeParseParams()
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.map((row) => row[0].v)).toEqual([
|
||||||
|
'00:00:42',
|
||||||
|
'09:05:03',
|
||||||
|
'23:00:00'
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads a 2-part value as HOURS-first and defaults seconds (HHMM/TIME5.): 0:42 -> 00:42:00', () => {
|
||||||
|
// TIME5.-7. and HHMM. columns render `HH:mm`. Such values are never `mm:ss`
|
||||||
|
// — DC only classifies TIME/HHMM formats as time columns — so the hour is
|
||||||
|
// padded and minutes kept, with seconds defaulted to :00.
|
||||||
|
const data = [[{ v: '0:42' }], [{ v: '12:25' }]]
|
||||||
|
|
||||||
|
const result = updateDateTimeCols(
|
||||||
|
buildSpreadsheetUtil(),
|
||||||
|
['SOME_TIME'],
|
||||||
|
data,
|
||||||
|
timeParseParams()
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.map((row) => row[0].v)).toEqual(['00:42:00', '12:25:00'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reconstructs an Excel serial time to zero-padded HH:mm:ss', () => {
|
||||||
|
// 0.0004861111 of a day = 42 seconds past midnight.
|
||||||
|
const data = [[{ v: 0.0004861111 }]]
|
||||||
|
|
||||||
|
const result = updateDateTimeCols(
|
||||||
|
buildSpreadsheetUtil(),
|
||||||
|
['SOME_TIME'],
|
||||||
|
data,
|
||||||
|
timeParseParams()
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result[0][0].v).toBe('00:00:42')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,7 +2,8 @@ import { isSpecialMissing } from '@sasjs/utils/input/validators'
|
|||||||
import {
|
import {
|
||||||
dateFormat,
|
dateFormat,
|
||||||
dateToUtcTime,
|
dateToUtcTime,
|
||||||
dateToTime
|
dateToTime,
|
||||||
|
padTimeString
|
||||||
} from 'src/app/editor/utils/date.utils'
|
} from 'src/app/editor/utils/date.utils'
|
||||||
import {
|
import {
|
||||||
getMissingHeaders,
|
getMissingHeaders,
|
||||||
@@ -608,7 +609,11 @@ export class SpreadsheetUtil {
|
|||||||
|
|
||||||
// If the cell does not have `v` property we ignore it, those are metadata properties
|
// If the cell does not have `v` property we ignore it, those are metadata properties
|
||||||
if (cellValue && typeof cellValue === 'string') {
|
if (cellValue && typeof cellValue === 'string') {
|
||||||
const potentialHeader = cellValue.toLowerCase()
|
// .trim(): header cells can pick up incidental leading/trailing
|
||||||
|
// whitespace from copy-pasting between spreadsheets/systems — treat
|
||||||
|
// that the same as the existing case-insensitive matching below,
|
||||||
|
// not as a genuinely different (missing) column.
|
||||||
|
const potentialHeader = cellValue.trim().toLowerCase()
|
||||||
const headerIndex = csvArrayHeadersLower.indexOf(potentialHeader)
|
const headerIndex = csvArrayHeadersLower.indexOf(potentialHeader)
|
||||||
|
|
||||||
if (headerIndex > -1) {
|
if (headerIndex > -1) {
|
||||||
@@ -852,11 +857,14 @@ export class SpreadsheetUtil {
|
|||||||
if (
|
if (
|
||||||
isStringNumber(obj.v) ||
|
isStringNumber(obj.v) ||
|
||||||
isStringDecimal(obj.v) ||
|
isStringDecimal(obj.v) ||
|
||||||
obj.v.includes('E-')
|
(typeof obj.v === 'string' && obj.v.includes('E-'))
|
||||||
) {
|
) {
|
||||||
const date = excelDateToJSDate(Number(obj.v))
|
const date = excelDateToJSDate(Number(obj.v))
|
||||||
|
|
||||||
obj.v = dateToUtcTime(date)
|
obj.v = dateToUtcTime(date)
|
||||||
|
} else if (typeof obj.v === 'string') {
|
||||||
|
// `0:00:42`— pad the hour for `intl-time` cell type
|
||||||
|
obj.v = padTimeString(obj.v)
|
||||||
}
|
}
|
||||||
row[element] = obj
|
row[element] = obj
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { buildColInfoHtml } from './col-info-html'
|
||||||
|
import { DataFormat } from '../../models/sas/common/DateFormat'
|
||||||
|
|
||||||
|
describe('buildColInfoHtml', () => {
|
||||||
|
it('returns "No info found" when colInfo is undefined', () => {
|
||||||
|
expect(buildColInfoHtml('SOME_CHAR', undefined)).toBe('No info found')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders NAME first, then LABEL/TYPE/LENGTH/FORMAT', () => {
|
||||||
|
const colInfo: DataFormat = {
|
||||||
|
label: 'Some Character Column',
|
||||||
|
type: 'char',
|
||||||
|
length: '1024',
|
||||||
|
format: '$1024.'
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(buildColInfoHtml('SOME_CHAR', colInfo)).toBe(
|
||||||
|
'NAME: SOME_CHAR<br>LABEL: Some Character Column<br>TYPE: char<br>LENGTH: 1024<br>FORMAT: $1024.'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { DataFormat } from '../../models/sas/common/DateFormat'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the HTML shown in a column-header "info" dropdown item (viewer and
|
||||||
|
* editor). NAME is listed first so it's visible regardless of whether
|
||||||
|
* headers are currently displayed as NAME or LABEL.
|
||||||
|
*/
|
||||||
|
export function buildColInfoHtml(
|
||||||
|
colName: string,
|
||||||
|
colInfo?: DataFormat
|
||||||
|
): string {
|
||||||
|
if (!colInfo) return 'No info found'
|
||||||
|
|
||||||
|
return `NAME: ${colName}<br>LABEL: ${colInfo.label}<br>TYPE: ${colInfo.type}<br>LENGTH: ${colInfo.length}<br>FORMAT: ${colInfo.format}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { getDisplayColHeaders } from './display-col-headers'
|
||||||
|
import { Col } from '../dc-validator/models/col.model'
|
||||||
|
|
||||||
|
const makeCol = (overrides: Partial<Col>): Col => ({
|
||||||
|
NAME: '',
|
||||||
|
VARNUM: 0,
|
||||||
|
LABEL: '',
|
||||||
|
FMTNAME: '',
|
||||||
|
DDTYPE: '',
|
||||||
|
TYPE: '',
|
||||||
|
CLS_RULE: '',
|
||||||
|
MEMLABEL: '',
|
||||||
|
DESC: '',
|
||||||
|
LONGDESC: '',
|
||||||
|
...overrides
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getDisplayColHeaders', () => {
|
||||||
|
const colNames = ['SOME_CHAR', 'SOME_NUM', 'SOME_DATE']
|
||||||
|
const cols: Col[] = [
|
||||||
|
makeCol({ NAME: 'SOME_CHAR', LABEL: 'Some Character Column' }),
|
||||||
|
makeCol({ NAME: 'SOME_NUM', LABEL: '' }),
|
||||||
|
makeCol({ NAME: 'SOME_DATE', LABEL: 'Some Date' })
|
||||||
|
]
|
||||||
|
|
||||||
|
it('returns colNames unchanged when useLabels is false', () => {
|
||||||
|
expect(getDisplayColHeaders(colNames, cols, false)).toEqual(colNames)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns matching LABELs when useLabels is true', () => {
|
||||||
|
expect(getDisplayColHeaders(colNames, cols, true)).toEqual([
|
||||||
|
'Some Character Column',
|
||||||
|
'SOME_NUM',
|
||||||
|
'Some Date'
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to NAME when LABEL is blank', () => {
|
||||||
|
const result = getDisplayColHeaders(['SOME_NUM'], cols, true)
|
||||||
|
expect(result).toEqual(['SOME_NUM'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to NAME when the column is missing from cols', () => {
|
||||||
|
const result = getDisplayColHeaders(
|
||||||
|
['SOME_CHAR', 'UNKNOWN_COL'],
|
||||||
|
cols,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
expect(result).toEqual(['Some Character Column', 'UNKNOWN_COL'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves colNames order, not cols order', () => {
|
||||||
|
const reordered = ['SOME_DATE', 'SOME_CHAR']
|
||||||
|
expect(getDisplayColHeaders(reordered, cols, true)).toEqual([
|
||||||
|
'Some Date',
|
||||||
|
'Some Character Column'
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Col } from '../dc-validator/models/col.model'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps grid column names to their display headers.
|
||||||
|
*
|
||||||
|
* When `useLabels` is true, each name is swapped for its `LABEL` from `cols`
|
||||||
|
* (falling back to the NAME itself when a column has no LABEL, or isn't
|
||||||
|
* present in `cols` at all). Order always follows `colNames` (grid order).
|
||||||
|
*/
|
||||||
|
export function getDisplayColHeaders(
|
||||||
|
colNames: string[],
|
||||||
|
cols: Col[],
|
||||||
|
useLabels: boolean
|
||||||
|
): string[] {
|
||||||
|
if (!useLabels) return colNames
|
||||||
|
|
||||||
|
const labelsByName = new Map(cols.map((col) => [col.NAME, col.LABEL]))
|
||||||
|
|
||||||
|
return colNames.map((name) => labelsByName.get(name) || name)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { parseEmbedParam } from './parse-embed-param'
|
||||||
|
|
||||||
|
describe('parseEmbedParam', () => {
|
||||||
|
it('returns false when there is no hash query', () => {
|
||||||
|
expect(parseEmbedParam('')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when the embed param is absent', () => {
|
||||||
|
expect(parseEmbedParam('foo=bar')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns 'va' for embed=va", () => {
|
||||||
|
expect(parseEmbedParam('embed=va')).toBe('va')
|
||||||
|
expect(parseEmbedParam('foo=bar&embed=va')).toBe('va')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false for embed=false', () => {
|
||||||
|
expect(parseEmbedParam('embed=false')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns true for embed=true and other truthy values', () => {
|
||||||
|
expect(parseEmbedParam('embed=true')).toBe(true)
|
||||||
|
expect(parseEmbedParam('embed=1')).toBe(true)
|
||||||
|
expect(parseEmbedParam('embed=')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Parses the `embed` value out of a hash query string (the part after `?` in
|
||||||
|
* `window.location.hash`).
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - `'va'` when `embed=va` (SAS Visual Analytics data-driven content mode)
|
||||||
|
* - `false` when `embed=false`, or when the param is absent
|
||||||
|
* - `true` for any other non-empty value (legacy boolean embed)
|
||||||
|
*
|
||||||
|
* `'va'` is intentionally truthy so existing `!embed` chrome checks keep hiding
|
||||||
|
* the header/back/subnav exactly as they do for `embed=true`.
|
||||||
|
*/
|
||||||
|
export function parseEmbedParam(hashQuery: string): boolean | 'va' {
|
||||||
|
if (!hashQuery) return false
|
||||||
|
|
||||||
|
const embedParam = new URLSearchParams(hashQuery).get('embed')
|
||||||
|
if (embedParam === null) return false
|
||||||
|
|
||||||
|
if (embedParam === 'va') return 'va'
|
||||||
|
|
||||||
|
return embedParam !== 'false'
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { parseLabelsParam } from './parse-labels-param'
|
||||||
|
|
||||||
|
describe('parseLabelsParam', () => {
|
||||||
|
it('returns false when there is no hash query', () => {
|
||||||
|
expect(parseLabelsParam('')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when the labels param is absent', () => {
|
||||||
|
expect(parseLabelsParam('foo=bar')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false for labels=false', () => {
|
||||||
|
expect(parseLabelsParam('labels=false')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns true for labels=true and other truthy values', () => {
|
||||||
|
expect(parseLabelsParam('labels=true')).toBe(true)
|
||||||
|
expect(parseLabelsParam('labels=1')).toBe(true)
|
||||||
|
expect(parseLabelsParam('labels=yes')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('coexists with other params, including embed', () => {
|
||||||
|
expect(parseLabelsParam('embed=va&labels=true')).toBe(true)
|
||||||
|
expect(parseLabelsParam('foo=bar&labels=false&baz=qux')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Parses the `labels` value out of a hash query string (the part after `?` in
|
||||||
|
* `window.location.hash`).
|
||||||
|
*
|
||||||
|
* Returns `true` for any non-`'false'` value (e.g. `labels=true`, `labels=1`),
|
||||||
|
* and `false` when `labels=false`, or when the param is absent.
|
||||||
|
*
|
||||||
|
* Modeled on `parseEmbedParam` — coexists with `embed` and other params.
|
||||||
|
*/
|
||||||
|
export function parseLabelsParam(hashQuery: string): boolean {
|
||||||
|
if (!hashQuery) return false
|
||||||
|
|
||||||
|
const labelsParam = new URLSearchParams(hashQuery).get('labels')
|
||||||
|
|
||||||
|
if (labelsParam === null) return false
|
||||||
|
|
||||||
|
return labelsParam !== 'false'
|
||||||
|
}
|
||||||
@@ -42,6 +42,10 @@ import { DataFormat } from '../models/sas/common/DateFormat'
|
|||||||
import { Libinfo } from '../models/sas/common/Libinfo'
|
import { Libinfo } from '../models/sas/common/Libinfo'
|
||||||
import { LicenceService } from '../services/licence.service'
|
import { LicenceService } from '../services/licence.service'
|
||||||
import { Location } from '@angular/common'
|
import { Location } from '@angular/common'
|
||||||
|
import { Col } from '../shared/dc-validator/models/col.model'
|
||||||
|
import { parseLabelsParam } from '../shared/utils/parse-labels-param'
|
||||||
|
import { getDisplayColHeaders } from '../shared/utils/display-col-headers'
|
||||||
|
import { buildColInfoHtml } from '../shared/utils/col-info-html'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-viewer',
|
selector: 'app-viewer',
|
||||||
@@ -74,7 +78,14 @@ export class ViewerComponent
|
|||||||
public table: any
|
public table: any
|
||||||
public tableuri: string | null = null
|
public tableuri: string | null = null
|
||||||
public filter: boolean = false
|
public filter: boolean = false
|
||||||
public filterCols: any = []
|
public filterCols: Col[] = []
|
||||||
|
// NAMEs of the currently loaded columns, in grid order — the canonical
|
||||||
|
// source `getDisplayColHeaders` maps against; `hotTable.colHeaders` holds
|
||||||
|
// whichever of NAME/LABEL is currently displayed.
|
||||||
|
public colNames: string[] = []
|
||||||
|
// `?labels=true` toggle (see parseLabelsParam) — URL is the source of
|
||||||
|
// truth, kept in sync via the queryParams subscription below.
|
||||||
|
public useLabels: boolean = false
|
||||||
public nullVariables: boolean = false
|
public nullVariables: boolean = false
|
||||||
public abortActive: boolean = false
|
public abortActive: boolean = false
|
||||||
public queryFilter: any
|
public queryFilter: any
|
||||||
@@ -190,7 +201,21 @@ export class ViewerComponent
|
|||||||
items: {
|
items: {
|
||||||
copy_with_column_headers: {},
|
copy_with_column_headers: {},
|
||||||
copy_column_headers_only: {},
|
copy_column_headers_only: {},
|
||||||
export_file: buildExportMenuItem(() => this.tableTitle || 'export')
|
export_file: buildExportMenuItem(() => this.tableTitle || 'export'),
|
||||||
|
// 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'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
copyPaste: {
|
copyPaste: {
|
||||||
@@ -219,6 +244,7 @@ export class ViewerComponent
|
|||||||
itemValue: string
|
itemValue: string
|
||||||
) => {
|
) => {
|
||||||
const elem = document.createElement('span')
|
const elem = document.createElement('span')
|
||||||
|
let colName = ''
|
||||||
let colInfo: DataFormat | undefined
|
let colInfo: DataFormat | undefined
|
||||||
let textInfo = 'No info found'
|
let textInfo = 'No info found'
|
||||||
|
|
||||||
@@ -228,18 +254,19 @@ export class ViewerComponent
|
|||||||
!this.isTableSwitching
|
!this.isTableSwitching
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const hotSelected: [number, number, number, number][] =
|
// getSelected() is typed number[][] in HOT 18 (was a 4-tuple
|
||||||
|
// array before); loosen the annotation to match.
|
||||||
|
const hotSelected: number[][] =
|
||||||
this.hotInstance.getSelected() || []
|
this.hotInstance.getSelected() || []
|
||||||
const selectedCol: number = hotSelected ? hotSelected[0][1] : -1
|
const selectedCol: number = hotSelected ? hotSelected[0][1] : -1
|
||||||
const colName = this.hotInstance.colToProp(selectedCol)
|
colName = this.hotInstance.colToProp(selectedCol) as string
|
||||||
colInfo = this.$dataFormats?.vars[colName]
|
colInfo = this.$dataFormats?.vars[colName]
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ignore errors during table switching
|
// Ignore errors during table switching
|
||||||
colInfo = undefined
|
colInfo = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
if (colInfo)
|
textInfo = buildColInfoHtml(colName, colInfo)
|
||||||
textInfo = `LABEL: ${colInfo?.label}<br>TYPE: ${colInfo?.type}<br>LENGTH: ${colInfo?.length}<br>FORMAT: ${colInfo?.format}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
elem.innerHTML = textInfo
|
elem.innerHTML = textInfo
|
||||||
@@ -252,6 +279,7 @@ export class ViewerComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _query!: Subscription
|
private _query!: Subscription
|
||||||
|
private _queryParams!: Subscription
|
||||||
|
|
||||||
private hotInstance: Handsontable | null = null
|
private hotInstance: Handsontable | null = null
|
||||||
public hotInstanceClickListener: boolean = false
|
public hotInstanceClickListener: boolean = false
|
||||||
@@ -299,6 +327,31 @@ export class ViewerComponent
|
|||||||
this.updateHotTableSettings() // Update settings when license key changes
|
this.updateHotTableSettings() // Update settings when license key changes
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recomputes `hotTable.colHeaders` from `colNames`/`filterCols` for the
|
||||||
|
* current `useLabels` state, and pushes it into a live grid via
|
||||||
|
* `updateSettings` if one is mounted (no refetch needed).
|
||||||
|
*/
|
||||||
|
private applyDisplayColHeaders() {
|
||||||
|
this.hotTable.colHeaders = getDisplayColHeaders(
|
||||||
|
this.colNames,
|
||||||
|
this.filterCols,
|
||||||
|
this.useLabels
|
||||||
|
)
|
||||||
|
|
||||||
|
if (this.hotInstance && !this.hotInstance.isDestroyed) {
|
||||||
|
this.hotInstance.updateSettings({ colHeaders: this.hotTable.colHeaders })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -943,11 +996,17 @@ export class ViewerComponent
|
|||||||
columns.push(colDef)
|
columns.push(colDef)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.hotTable.colHeaders = colArr
|
this.colNames = colArr
|
||||||
|
this.hotTable.colHeaders = getDisplayColHeaders(
|
||||||
|
colArr,
|
||||||
|
res.cols,
|
||||||
|
this.useLabels
|
||||||
|
)
|
||||||
this.hotTable.columns = columns
|
this.hotTable.columns = columns
|
||||||
this.hiddenViewColumns = hiddenColumnIndexes
|
this.hiddenViewColumns = hiddenColumnIndexes
|
||||||
} else {
|
} else {
|
||||||
// Set empty arrays if no data
|
// Set empty arrays if no data
|
||||||
|
this.colNames = []
|
||||||
this.hotTable.colHeaders = []
|
this.hotTable.colHeaders = []
|
||||||
this.hotTable.columns = []
|
this.hotTable.columns = []
|
||||||
this.hiddenViewColumns = []
|
this.hiddenViewColumns = []
|
||||||
@@ -1396,6 +1455,8 @@ export class ViewerComponent
|
|||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
// Proper component destruction to prevent memory leaks and errors
|
// Proper component destruction to prevent memory leaks and errors
|
||||||
|
|
||||||
|
this._queryParams?.unsubscribe()
|
||||||
|
|
||||||
// Prevent any new operations during cleanup
|
// Prevent any new operations during cleanup
|
||||||
this.isTableSwitching = true
|
this.isTableSwitching = true
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/*
|
||||||
|
* SAS Visual Analytics data-driven content: earliest-possible message capture.
|
||||||
|
*
|
||||||
|
* Loaded as an external script from index.html (CSP-safe under a strict policy
|
||||||
|
* — no inline script needed) and executed at HTML parse time, long before the
|
||||||
|
* Angular app bootstraps. SAS VA posts its DDC data message once on iframe
|
||||||
|
* load, which happens before Angular is up, so without this the very first
|
||||||
|
* message (and any pre-set filter it carries) is lost.
|
||||||
|
*
|
||||||
|
* The latest captured message is stored on window.__vaLastMessage;
|
||||||
|
* VaMessagingService reads it when it subscribes (closing the startup race) and
|
||||||
|
* to recover the latest message across editor reloads.
|
||||||
|
*
|
||||||
|
* Self-guards on ?embed=va so it is completely inert in normal (non-VA) use.
|
||||||
|
*/
|
||||||
|
;(function () {
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
try {
|
||||||
|
var hash = window.location.hash || ''
|
||||||
|
var query = hash.indexOf('?') >= 0 ? hash.split('?')[1] : ''
|
||||||
|
if (new URLSearchParams(query).get('embed') !== 'va') return
|
||||||
|
} catch (e) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener(
|
||||||
|
'message',
|
||||||
|
function (event) {
|
||||||
|
var data = event && event.data
|
||||||
|
if (
|
||||||
|
data &&
|
||||||
|
typeof data === 'object' &&
|
||||||
|
typeof data.resultName === 'string'
|
||||||
|
) {
|
||||||
|
// Store ONLY the latest message — readers just need the most recent, so
|
||||||
|
// a single value keeps this bounded across a long session of filters.
|
||||||
|
window.__vaLastMessage = {
|
||||||
|
t: Date.now(),
|
||||||
|
origin: event.origin,
|
||||||
|
data: data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
false
|
||||||
|
)
|
||||||
|
})()
|
||||||
@@ -5,6 +5,15 @@
|
|||||||
<title>Data Controller</title>
|
<title>Data Controller</title>
|
||||||
<!-- <base href="/"> -->
|
<!-- <base href="/"> -->
|
||||||
|
|
||||||
|
<!--
|
||||||
|
SAS VA data-driven content: earliest-possible message capture runs before
|
||||||
|
Angular bootstraps. It is the `va-early` global script bundled via
|
||||||
|
angular.json (src/assets/va-early.js) and auto-injected here at build time
|
||||||
|
— external (not inline) so it passes a strict CSP, and it self-guards on
|
||||||
|
?embed=va so it is inert in normal use. See
|
||||||
|
VaMessagingService.replayEarlyMessages.
|
||||||
|
-->
|
||||||
|
|
||||||
<!-- meta tags -->
|
<!-- meta tags -->
|
||||||
<meta name="description" content="Capture, Review, and Approve" />
|
<meta name="description" content="Capture, Review, and Approve" />
|
||||||
<meta itemprop="name" content="Data Controller for SAS®" />
|
<meta itemprop="name" content="Data Controller for SAS®" />
|
||||||
|
|||||||
Generated
+3
-20
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "dcfrontend",
|
"name": "dcfrontend",
|
||||||
"version": "7.7.2",
|
"version": "7.8.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "dcfrontend",
|
"name": "dcfrontend",
|
||||||
"version": "7.7.2",
|
"version": "7.8.2",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@saithodev/semantic-release-gitea": "^2.1.0",
|
"@saithodev/semantic-release-gitea": "^2.1.0",
|
||||||
@@ -15,8 +15,7 @@
|
|||||||
"@semantic-release/git": "^10.0.1",
|
"@semantic-release/git": "^10.0.1",
|
||||||
"@semantic-release/npm": "13.1.5",
|
"@semantic-release/npm": "13.1.5",
|
||||||
"@semantic-release/release-notes-generator": "14.1.0",
|
"@semantic-release/release-notes-generator": "14.1.0",
|
||||||
"commit-and-tag-version": "12.7.1",
|
"commit-and-tag-version": "12.7.1"
|
||||||
"prettier": "^3.7.4"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@actions/core": {
|
"node_modules/@actions/core": {
|
||||||
@@ -5962,22 +5961,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prettier": {
|
|
||||||
"version": "3.7.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz",
|
|
||||||
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"prettier": "bin/prettier.cjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pretty-ms": {
|
"node_modules/pretty-ms": {
|
||||||
"version": "9.3.0",
|
"version": "9.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz",
|
||||||
|
|||||||
+7
-8
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dcfrontend",
|
"name": "dcfrontend",
|
||||||
"version": "7.8.2",
|
"version": "7.10.1",
|
||||||
"description": "Data Controller",
|
"description": "Data Controller",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@saithodev/semantic-release-gitea": "^2.1.0",
|
"@saithodev/semantic-release-gitea": "^2.1.0",
|
||||||
@@ -9,8 +9,7 @@
|
|||||||
"@semantic-release/git": "^10.0.1",
|
"@semantic-release/git": "^10.0.1",
|
||||||
"@semantic-release/npm": "13.1.5",
|
"@semantic-release/npm": "13.1.5",
|
||||||
"@semantic-release/release-notes-generator": "14.1.0",
|
"@semantic-release/release-notes-generator": "14.1.0",
|
||||||
"commit-and-tag-version": "12.7.1",
|
"commit-and-tag-version": "12.7.1"
|
||||||
"prettier": "^3.7.4"
|
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"got": "11.8.6"
|
"got": "11.8.6"
|
||||||
@@ -19,11 +18,11 @@
|
|||||||
"install": "cd client && npm i && cd ../sas && npm i",
|
"install": "cd client && npm i && cd ../sas && npm i",
|
||||||
"build-frontend": "cd client && npm run build",
|
"build-frontend": "cd client && npm run build",
|
||||||
"release": "commit-and-tag-version",
|
"release": "commit-and-tag-version",
|
||||||
"lint": "npm run lint:fix",
|
"lint": "cd client && npm run lint",
|
||||||
"lint:fix": "npx prettier --write \"client/{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"client/cypress/e2e/*.cy.ts\"",
|
"lint:fix": "cd client && npm run lint:fix",
|
||||||
"lint:fix:silent": "npx prettier --log-level silent --write \"client/{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"client/cypress/e2e/*.cy.ts\"",
|
"lint:fix:silent": "cd client && npm run lint:fix:silent",
|
||||||
"lint:check": "npx prettier --check \"client/{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"client/cypress/e2e/*.cy.ts\"",
|
"lint:check": "cd client && npm run lint:check",
|
||||||
"lint:check:silent": "npx prettier --log-level silent --check \"client/{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"client/cypress/e2e/*.cy.ts\"",
|
"lint:check:silent": "cd client && npm run lint:check:silent",
|
||||||
"jo": "echo",
|
"jo": "echo",
|
||||||
"prepare": "git rev-parse --git-dir && git config core.hooksPath ./.git-hooks && git config core.autocrlf false || true"
|
"prepare": "git rev-parse --git-dir && git config core.hooksPath ./.git-hooks && git config core.autocrlf false || true"
|
||||||
},
|
},
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -73,7 +73,8 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"PRIMARY_KEY_FIELD\",\"type\":\"numeric\",\"format\":\"0\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "SOME_BESTNUM",
|
NAME: "SOME_BESTNUM",
|
||||||
@@ -84,29 +85,32 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"SOME_BESTNUM\",\"type\":\"numeric\",\"format\":\"0\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "SOME_CHAR",
|
NAME: "SOME_CHAR",
|
||||||
VARNUM: 2,
|
VARNUM: 2,
|
||||||
LABEL: "SOME_CHAR",
|
LABEL: "Some Character Column",
|
||||||
FMTNAME: "",
|
FMTNAME: "",
|
||||||
DDTYPE: "CHARACTER",
|
DDTYPE: "CHARACTER",
|
||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"SOME_CHAR\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "SOME_DATE",
|
NAME: "SOME_DATE",
|
||||||
VARNUM: 5,
|
VARNUM: 5,
|
||||||
LABEL: "SOME_DATE",
|
LABEL: "Some Date",
|
||||||
FMTNAME: "DATE",
|
FMTNAME: "DATE",
|
||||||
DDTYPE: "DATE",
|
DDTYPE: "DATE",
|
||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"SOME_DATE\",\"type\":\"date\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "SOME_DATETIME",
|
NAME: "SOME_DATETIME",
|
||||||
@@ -117,7 +121,8 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"SOME_DATETIME\",\"type\":\"datetime\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "SOME_DROPDOWN",
|
NAME: "SOME_DROPDOWN",
|
||||||
@@ -128,7 +133,8 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"SOME_DROPDOWN\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "SOME_HARDSELECT",
|
NAME: "SOME_HARDSELECT",
|
||||||
@@ -139,18 +145,20 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"SOME_HARDSELECT\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "SOME_NUM",
|
NAME: "SOME_NUM",
|
||||||
VARNUM: 4,
|
VARNUM: 4,
|
||||||
LABEL: "SOME_NUM",
|
LABEL: "",
|
||||||
FMTNAME: "",
|
FMTNAME: "",
|
||||||
DDTYPE: "NUMERIC",
|
DDTYPE: "NUMERIC",
|
||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"SOME_NUM\",\"type\":\"numeric\",\"format\":\"0\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "SOME_SHORTNUM",
|
NAME: "SOME_SHORTNUM",
|
||||||
@@ -161,7 +169,8 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"SOME_SHORTNUM\",\"type\":\"numeric\",\"format\":\"0\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "SOME_TIME",
|
NAME: "SOME_TIME",
|
||||||
@@ -172,7 +181,8 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"SOME_TIME\",\"type\":\"time\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "READONLY_COL",
|
NAME: "READONLY_COL",
|
||||||
@@ -183,7 +193,8 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "Read-only: default value inserted on add-row, not editable",
|
DESC: "Read-only: default value inserted on add-row, not editable",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"READONLY_COL\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "HIDDEN_COL",
|
NAME: "HIDDEN_COL",
|
||||||
@@ -194,7 +205,8 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "Hidden: invisible in grid but submitted; default on add-row",
|
DESC: "Hidden: invisible in grid but submitted; default on add-row",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"HIDDEN_COL\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "ROUND_COL",
|
NAME: "ROUND_COL",
|
||||||
@@ -205,7 +217,8 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "Round: edited values rounded Excel-style to 2 decimals",
|
DESC: "Round: edited values rounded Excel-style to 2 decimals",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"ROUND_COL\",\"type\":\"numeric\",\"format\":\"0\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "NUMFMT_COL",
|
NAME: "NUMFMT_COL",
|
||||||
@@ -216,7 +229,8 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "Number format: displayed as EUR currency (value unchanged)",
|
DESC: "Number format: displayed as EUR currency (value unchanged)",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"NUMFMT_COL\",\"type\":\"numeric\",\"format\":\"0\"}"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
dqdata: [
|
dqdata: [
|
||||||
@@ -323,7 +337,6 @@ let webouts = {
|
|||||||
DTVARS: " SOME_DATE",
|
DTVARS: " SOME_DATE",
|
||||||
DTTMVARS: " SOME_DATETIME",
|
DTTMVARS: " SOME_DATETIME",
|
||||||
TMVARS: " SOME_TIME",
|
TMVARS: " SOME_TIME",
|
||||||
COLTYPE: "{\"data\":\"_____DELETE__THIS__RECORD_____\",\"type\":\"dropdown\",\"source\":[\"No\",\"Yes\"]},{\"data\":\"PRIMARY_KEY_FIELD\",\"type\":\"numeric\",\"format\":\"0\"},{\"data\":\"SOME_CHAR\"},{\"data\":\"SOME_DROPDOWN\"},{\"data\":\"SOME_HARDSELECT\"},{\"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\"},{\"data\":\"READONLY_COL\"},{\"data\":\"HIDDEN_COL\"},{\"data\":\"ROUND_COL\",\"type\":\"numeric\",\"format\":\"0\"},{\"data\":\"NUMFMT_COL\",\"type\":\"numeric\",\"format\":\"0\"}",
|
|
||||||
LOADTYPE: "UPDATE",
|
LOADTYPE: "UPDATE",
|
||||||
RK_FLAG: 0,
|
RK_FLAG: 0,
|
||||||
CLS_FLAG: 0
|
CLS_FLAG: 0
|
||||||
@@ -366,7 +379,8 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"DD_LONGDESC\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "DD_OWNER",
|
NAME: "DD_OWNER",
|
||||||
@@ -377,7 +391,8 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"DD_OWNER\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "DD_RESPONSIBLE",
|
NAME: "DD_RESPONSIBLE",
|
||||||
@@ -388,7 +403,8 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"DD_RESPONSIBLE\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "DD_SENSITIVITY",
|
NAME: "DD_SENSITIVITY",
|
||||||
@@ -399,7 +415,8 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"DD_SENSITIVITY\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "DD_SHORTDESC",
|
NAME: "DD_SHORTDESC",
|
||||||
@@ -410,7 +427,8 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"DD_SHORTDESC\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "DD_SOURCE",
|
NAME: "DD_SOURCE",
|
||||||
@@ -421,7 +439,8 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"DD_SOURCE\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "DD_TYPE",
|
NAME: "DD_TYPE",
|
||||||
@@ -432,7 +451,8 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"DD_TYPE\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "TX_FROM",
|
NAME: "TX_FROM",
|
||||||
@@ -593,7 +613,6 @@ let webouts = {
|
|||||||
DTVARS: "",
|
DTVARS: "",
|
||||||
DTTMVARS: "",
|
DTTMVARS: "",
|
||||||
TMVARS: "",
|
TMVARS: "",
|
||||||
COLTYPE: "{\"data\":\"_____DELETE__THIS__RECORD_____\",\"type\":\"dropdown\",\"source\":[\"No\",\"Yes\"]},{\"data\":\"DD_TYPE\"},{\"data\":\"DD_SOURCE\"},{\"data\":\"DD_SHORTDESC\"},{\"data\":\"DD_LONGDESC\"},{\"data\":\"DD_OWNER\"},{\"data\":\"DD_RESPONSIBLE\"},{\"data\":\"DD_SENSITIVITY\"}",
|
|
||||||
LOADTYPE: "TXTEMPORAL",
|
LOADTYPE: "TXTEMPORAL",
|
||||||
RK_FLAG: 0,
|
RK_FLAG: 0,
|
||||||
CLS_FLAG: 0
|
CLS_FLAG: 0
|
||||||
@@ -660,7 +679,8 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"USER_ID\"}"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
dqdata: [],
|
dqdata: [],
|
||||||
@@ -722,7 +742,6 @@ let webouts = {
|
|||||||
DTVARS: "",
|
DTVARS: "",
|
||||||
DTTMVARS: "",
|
DTTMVARS: "",
|
||||||
TMVARS: "",
|
TMVARS: "",
|
||||||
COLTYPE: "{\"data\":\"_____DELETE__THIS__RECORD_____\",\"type\":\"dropdown\",\"source\":[\"No\",\"Yes\"]},{\"data\":\"USER_ID\"}",
|
|
||||||
LOADTYPE: "UPDATE",
|
LOADTYPE: "UPDATE",
|
||||||
RK_FLAG: 0,
|
RK_FLAG: 0,
|
||||||
CLS_FLAG: 0
|
CLS_FLAG: 0
|
||||||
@@ -765,7 +784,8 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"AUDIT_LIBDS\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "BUSKEY",
|
NAME: "BUSKEY",
|
||||||
@@ -776,7 +796,8 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"BUSKEY\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "CLOSE_VARS",
|
NAME: "CLOSE_VARS",
|
||||||
@@ -787,7 +808,8 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"CLOSE_VARS\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "DSN",
|
NAME: "DSN",
|
||||||
@@ -798,7 +820,8 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"DSN\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "LIBREF",
|
NAME: "LIBREF",
|
||||||
@@ -809,7 +832,8 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"LIBREF\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "LOADTYPE",
|
NAME: "LOADTYPE",
|
||||||
@@ -820,7 +844,8 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"LOADTYPE\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "NOTES",
|
NAME: "NOTES",
|
||||||
@@ -831,7 +856,8 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"NOTES\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "NUM_OF_APPROVALS_REQUIRED",
|
NAME: "NUM_OF_APPROVALS_REQUIRED",
|
||||||
@@ -842,7 +868,8 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"NUM_OF_APPROVALS_REQUIRED\",\"type\":\"numeric\",\"format\":\"0\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "POST_APPROVE_HOOK",
|
NAME: "POST_APPROVE_HOOK",
|
||||||
@@ -853,7 +880,8 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"POST_APPROVE_HOOK\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "POST_EDIT_HOOK",
|
NAME: "POST_EDIT_HOOK",
|
||||||
@@ -864,7 +892,8 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"POST_EDIT_HOOK\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "PRE_APPROVE_HOOK",
|
NAME: "PRE_APPROVE_HOOK",
|
||||||
@@ -875,7 +904,8 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"PRE_APPROVE_HOOK\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "PRE_EDIT_HOOK",
|
NAME: "PRE_EDIT_HOOK",
|
||||||
@@ -886,7 +916,8 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"PRE_EDIT_HOOK\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "RK_UNDERLYING",
|
NAME: "RK_UNDERLYING",
|
||||||
@@ -897,7 +928,8 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"RK_UNDERLYING\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "SIGNOFF_COLS",
|
NAME: "SIGNOFF_COLS",
|
||||||
@@ -908,7 +940,8 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"SIGNOFF_COLS\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "SIGNOFF_HOOK",
|
NAME: "SIGNOFF_HOOK",
|
||||||
@@ -919,7 +952,8 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"SIGNOFF_HOOK\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "TX_FROM",
|
NAME: "TX_FROM",
|
||||||
@@ -952,7 +986,8 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"VAR_BUSFROM\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "VAR_BUSTO",
|
NAME: "VAR_BUSTO",
|
||||||
@@ -963,7 +998,8 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"VAR_BUSTO\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "VAR_PROCESSED",
|
NAME: "VAR_PROCESSED",
|
||||||
@@ -974,7 +1010,8 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"VAR_PROCESSED\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "VAR_TXFROM",
|
NAME: "VAR_TXFROM",
|
||||||
@@ -985,7 +1022,8 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"VAR_TXFROM\"}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
NAME: "VAR_TXTO",
|
NAME: "VAR_TXTO",
|
||||||
@@ -996,7 +1034,8 @@ let webouts = {
|
|||||||
CLS_RULE: "READ",
|
CLS_RULE: "READ",
|
||||||
MEMLABEL: "",
|
MEMLABEL: "",
|
||||||
DESC: "",
|
DESC: "",
|
||||||
LONGDESC: ""
|
LONGDESC: "",
|
||||||
|
COLTYPE: "{\"data\":\"VAR_TXTO\"}"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
dqdata: [
|
dqdata: [
|
||||||
@@ -1445,7 +1484,6 @@ let webouts = {
|
|||||||
DTVARS: "",
|
DTVARS: "",
|
||||||
DTTMVARS: "",
|
DTTMVARS: "",
|
||||||
TMVARS: "",
|
TMVARS: "",
|
||||||
COLTYPE: "{\"data\":\"_____DELETE__THIS__RECORD_____\",\"type\":\"dropdown\",\"source\":[\"No\",\"Yes\"]},{\"data\":\"LIBREF\"},{\"data\":\"DSN\"},{\"data\":\"NUM_OF_APPROVALS_REQUIRED\",\"type\":\"numeric\",\"format\":\"0\"},{\"data\":\"LOADTYPE\"},{\"data\":\"BUSKEY\"},{\"data\":\"VAR_TXFROM\"},{\"data\":\"VAR_TXTO\"},{\"data\":\"VAR_BUSFROM\"},{\"data\":\"VAR_BUSTO\"},{\"data\":\"VAR_PROCESSED\"},{\"data\":\"CLOSE_VARS\"},{\"data\":\"PRE_EDIT_HOOK\"},{\"data\":\"POST_EDIT_HOOK\"},{\"data\":\"PRE_APPROVE_HOOK\"},{\"data\":\"POST_APPROVE_HOOK\"},{\"data\":\"SIGNOFF_COLS\"},{\"data\":\"SIGNOFF_HOOK\"},{\"data\":\"NOTES\"},{\"data\":\"RK_UNDERLYING\"},{\"data\":\"AUDIT_LIBDS\"}",
|
|
||||||
LOADTYPE: "TXTEMPORAL",
|
LOADTYPE: "TXTEMPORAL",
|
||||||
RK_FLAG: 0,
|
RK_FLAG: 0,
|
||||||
CLS_FLAG: 0
|
CLS_FLAG: 0
|
||||||
@@ -1499,11 +1537,7 @@ function stripRuleCols(t) {
|
|||||||
})
|
})
|
||||||
t.sasparams = t.sasparams.map((p) => {
|
t.sasparams = t.sasparams.map((p) => {
|
||||||
const headers = p.COLHEADERS.split(',').filter((h) => !uc.has(h))
|
const headers = p.COLHEADERS.split(',').filter((h) => !uc.has(h))
|
||||||
const coltype = JSON.parse('[' + p.COLTYPE + ']')
|
return { ...p, COLHEADERS: headers.join(',') }
|
||||||
.filter((o) => !uc.has(o.data))
|
|
||||||
.map((o) => JSON.stringify(o))
|
|
||||||
.join(',')
|
|
||||||
return { ...p, COLHEADERS: headers.join(','), COLTYPE: coltype }
|
|
||||||
})
|
})
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -52,7 +52,7 @@ let webouts = {
|
|||||||
"NAME": "SOME_CHAR",
|
"NAME": "SOME_CHAR",
|
||||||
"LENGTH": 32767,
|
"LENGTH": 32767,
|
||||||
"VARNUM": 2,
|
"VARNUM": 2,
|
||||||
"LABEL": "SOME_CHAR",
|
"LABEL": "Some Character Column",
|
||||||
"FMTNAME": "",
|
"FMTNAME": "",
|
||||||
"FORMAT": "$32767.",
|
"FORMAT": "$32767.",
|
||||||
"TYPE": "C",
|
"TYPE": "C",
|
||||||
@@ -62,7 +62,7 @@ let webouts = {
|
|||||||
"NAME": "SOME_DATE",
|
"NAME": "SOME_DATE",
|
||||||
"LENGTH": 8,
|
"LENGTH": 8,
|
||||||
"VARNUM": 5,
|
"VARNUM": 5,
|
||||||
"LABEL": "SOME_DATE",
|
"LABEL": "Some Date",
|
||||||
"FMTNAME": "DATE",
|
"FMTNAME": "DATE",
|
||||||
"FORMAT": "DATE9.",
|
"FORMAT": "DATE9.",
|
||||||
"TYPE": "N",
|
"TYPE": "N",
|
||||||
@@ -92,7 +92,7 @@ let webouts = {
|
|||||||
"NAME": "SOME_NUM",
|
"NAME": "SOME_NUM",
|
||||||
"LENGTH": 8,
|
"LENGTH": 8,
|
||||||
"VARNUM": 4,
|
"VARNUM": 4,
|
||||||
"LABEL": "SOME_NUM",
|
"LABEL": "",
|
||||||
"FMTNAME": "",
|
"FMTNAME": "",
|
||||||
"FORMAT": "8.",
|
"FORMAT": "8.",
|
||||||
"TYPE": "N",
|
"TYPE": "N",
|
||||||
|
|||||||
Generated
+26
-38
@@ -6,8 +6,8 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "dc-sas",
|
"name": "dc-sas",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sasjs/cli": "4.17.4",
|
"@sasjs/cli": "4.18.0",
|
||||||
"@sasjs/core": "4.67.1"
|
"@sasjs/core": "4.67.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@asamuzakjp/css-color": {
|
"node_modules/@asamuzakjp/css-color": {
|
||||||
@@ -124,6 +124,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
@@ -146,6 +147,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -200,15 +202,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@sasjs/adapter": {
|
"node_modules/@sasjs/adapter": {
|
||||||
"version": "4.16.7",
|
"version": "4.17.1",
|
||||||
"resolved": "https://registry.npmjs.org/@sasjs/adapter/-/adapter-4.16.7.tgz",
|
"resolved": "https://registry.npmjs.org/@sasjs/adapter/-/adapter-4.17.1.tgz",
|
||||||
"integrity": "sha512-pOKAhOPijr663PFWGx+JEcWaMOGwICMA0w8LeXHPHQwvbHVGPCJKV5QroGqrjTO62HlhsHzow4Vb+DjIui1jgQ==",
|
"integrity": "sha512-MxTvAoVnr+sBmKtB+CHxDiwvsqfzBqQ3BT/Q2Of6g7WWzJRBvpJcpf6C9R80Uq/cxvEie9JAM3EfExtFEYlcLg==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sasjs/utils": "^3.5.6",
|
"@sasjs/utils": "^3.5.6",
|
||||||
"axios": "1.16.0",
|
"axios": "1.16.0",
|
||||||
"axios-cookiejar-support": "5.0.5",
|
"axios-cookiejar-support": "5.0.5",
|
||||||
"form-data": "4.0.4",
|
"form-data": "4.0.6",
|
||||||
"https": "1.0.0",
|
"https": "1.0.0",
|
||||||
"tough-cookie": "4.1.3"
|
"tough-cookie": "4.1.3"
|
||||||
}
|
}
|
||||||
@@ -249,13 +251,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@sasjs/cli": {
|
"node_modules/@sasjs/cli": {
|
||||||
"version": "4.17.4",
|
"version": "4.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/@sasjs/cli/-/cli-4.17.4.tgz",
|
"resolved": "https://registry.npmjs.org/@sasjs/cli/-/cli-4.18.0.tgz",
|
||||||
"integrity": "sha512-lNkIy6sojgT5mRQ/tkh8lyrIxNhrREw+CTkfEmy+mDiOh9COfGWMoSpBjRn2iqHX+dwOKaUlq6UwU2zHDLOmqQ==",
|
"integrity": "sha512-QFsLS9XXF64x9w51kYkmx3ZUWCA9ycVDeLIaNJpHcaTu6jgPRAVHQ61ShXBKlYp/Vrze9pNapW43iaXt7o6VEA==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sasjs/adapter": "4.16.7",
|
"@sasjs/adapter": "4.17.1",
|
||||||
"@sasjs/core": "4.67.1",
|
"@sasjs/core": "4.67.3",
|
||||||
"@sasjs/lint": "2.4.3",
|
"@sasjs/lint": "2.4.3",
|
||||||
"@sasjs/utils": "3.5.8",
|
"@sasjs/utils": "3.5.8",
|
||||||
"adm-zip": "0.5.10",
|
"adm-zip": "0.5.10",
|
||||||
@@ -315,9 +317,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@sasjs/core": {
|
"node_modules/@sasjs/core": {
|
||||||
"version": "4.67.1",
|
"version": "4.67.3",
|
||||||
"resolved": "https://registry.npmjs.org/@sasjs/core/-/core-4.67.1.tgz",
|
"resolved": "https://registry.npmjs.org/@sasjs/core/-/core-4.67.3.tgz",
|
||||||
"integrity": "sha512-yb4xW8JxsWWY3ZTN4yj/mP8ZasPIHSQVjHESLui3xS0HNatBw96H9ZYGJO3LcrkDx+X5xJz45f6sdHAJYxVeSA==",
|
"integrity": "sha512-7s4UeVN4slF1URo1C5/UnNVv+TonUg+vBktFRiFhcbFYr3RnfXyClBPED6nQOF8AFd2xdXEss58hsCeIebr4lg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@sasjs/lint": {
|
"node_modules/@sasjs/lint": {
|
||||||
@@ -464,6 +466,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
|
||||||
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
|
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.16.0",
|
"follow-redirects": "^1.16.0",
|
||||||
"form-data": "^4.0.5",
|
"form-data": "^4.0.5",
|
||||||
@@ -489,22 +492,6 @@
|
|||||||
"tough-cookie": ">=4.0.0"
|
"tough-cookie": ">=4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/axios/node_modules/form-data": {
|
|
||||||
"version": "4.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
|
||||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"asynckit": "^0.4.0",
|
|
||||||
"combined-stream": "^1.0.8",
|
|
||||||
"es-set-tostringtag": "^2.1.0",
|
|
||||||
"hasown": "^2.0.2",
|
|
||||||
"mime-types": "^2.1.12"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/base64-js": {
|
"node_modules/base64-js": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
@@ -1031,16 +1018,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/form-data": {
|
"node_modules/form-data": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||||
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"asynckit": "^0.4.0",
|
"asynckit": "^0.4.0",
|
||||||
"combined-stream": "^1.0.8",
|
"combined-stream": "^1.0.8",
|
||||||
"es-set-tostringtag": "^2.1.0",
|
"es-set-tostringtag": "^2.1.0",
|
||||||
"hasown": "^2.0.2",
|
"hasown": "^2.0.4",
|
||||||
"mime-types": "^2.1.12"
|
"mime-types": "^2.1.35"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
@@ -1194,9 +1181,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
@@ -2169,6 +2156,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz",
|
||||||
"integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==",
|
"integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"psl": "^1.1.33",
|
"psl": "^1.1.33",
|
||||||
"punycode": "^2.1.1",
|
"punycode": "^2.1.1",
|
||||||
|
|||||||
+2
-2
@@ -28,7 +28,7 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sasjs/cli": "4.17.4",
|
"@sasjs/cli": "4.18.0",
|
||||||
"@sasjs/core": "4.67.1"
|
"@sasjs/core": "4.67.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
@file
|
||||||
|
@brief migration script to move to v7.9 of Data Controller
|
||||||
|
|
||||||
|
OPTIONAL CHANGE - upload additional validation rules in dropdown (data only)
|
||||||
|
|
||||||
|
**/
|
||||||
|
|
||||||
|
%let dclib=YOURDCLIB;
|
||||||
|
|
||||||
|
libname &dclib "/YOUR/DATACONTROLLER/LIBRARY/PATH";
|
||||||
|
|
||||||
|
|
||||||
|
/* add new validation rules */
|
||||||
|
proc sql noprint;
|
||||||
|
select max(selectbox_rk) into: maxrk
|
||||||
|
from &dclib..mpe_selectbox;
|
||||||
|
insert into &dclib..mpe_selectbox set
|
||||||
|
selectbox_rk=&maxrk+1
|
||||||
|
,ver_from_dttm=0
|
||||||
|
,select_lib="&dclib"
|
||||||
|
,select_ds="MPE_VALIDATIONS"
|
||||||
|
,base_column="RULE_TYPE"
|
||||||
|
,selectbox_value='NUMBER_FORMAT'
|
||||||
|
,selectbox_order=9
|
||||||
|
,ver_to_dttm='31DEC5999:23:59:59'dt;
|
||||||
|
insert into &dclib..mpe_selectbox set
|
||||||
|
selectbox_rk=&maxrk+2
|
||||||
|
,ver_from_dttm=0
|
||||||
|
,select_lib="&dclib"
|
||||||
|
,select_ds="MPE_VALIDATIONS"
|
||||||
|
,base_column="RULE_TYPE"
|
||||||
|
,selectbox_value='READONLY'
|
||||||
|
,selectbox_order=10
|
||||||
|
,ver_to_dttm='31DEC5999:23:59:59'dt;
|
||||||
|
insert into &dclib..mpe_selectbox set
|
||||||
|
selectbox_rk=&maxrk+3
|
||||||
|
,ver_from_dttm=0
|
||||||
|
,select_lib="&dclib"
|
||||||
|
,select_ds="MPE_VALIDATIONS"
|
||||||
|
,base_column="RULE_TYPE"
|
||||||
|
,selectbox_value='HIDDEN'
|
||||||
|
,selectbox_order=11
|
||||||
|
,ver_to_dttm='31DEC5999:23:59:59'dt;
|
||||||
|
insert into &dclib..mpe_selectbox set
|
||||||
|
selectbox_rk=&maxrk+4
|
||||||
|
,ver_from_dttm=0
|
||||||
|
,select_lib="&dclib"
|
||||||
|
,select_ds="MPE_VALIDATIONS"
|
||||||
|
,base_column="RULE_TYPE"
|
||||||
|
,selectbox_value='ROUND'
|
||||||
|
,selectbox_order=12
|
||||||
|
,ver_to_dttm='31DEC5999:23:59:59'dt;
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
@file
|
||||||
|
@brief migration script
|
||||||
|
|
||||||
|
**/
|
||||||
|
|
||||||
|
%let dclib=YOURDCLIB;
|
||||||
|
|
||||||
|
libname &dclib "/YOUR/DATACONTROLLER/LIBRARY/PATH";
|
||||||
|
|
||||||
|
/* add new validation rules */
|
||||||
|
proc sql noprint;
|
||||||
|
insert into &dclib..mpe_config set
|
||||||
|
tx_from=%sysfunc(datetime())
|
||||||
|
,tx_to='31DEC9999:23:59:59'dt
|
||||||
|
,var_scope="DC"
|
||||||
|
,var_name="DC_MAXOBS_WEBVIEW"
|
||||||
|
,var_value='500'
|
||||||
|
,var_active=1
|
||||||
|
,var_desc='Default rows displayed for tables in VIEW mode.';
|
||||||
|
|
||||||
@@ -1,7 +1,29 @@
|
|||||||
/**
|
/**
|
||||||
@file mpe_getlabels.sas
|
@file mpe_getlabels.sas
|
||||||
@brief Gets the table and column labels for a particular table
|
@brief Gets the table and column labels for a particular table
|
||||||
@details Takes labels first from mpe_datadictionary then from table metadata.
|
@details Builds a labels dataset by combining three inputs:
|
||||||
|
|
||||||
|
@li NAME - taken from `source`. This is typically a WORK subset of
|
||||||
|
the underlying table (e.g. after column-level security has been
|
||||||
|
applied) and dictates which columns appear in the output.
|
||||||
|
@li MEMLABEL / LABEL - taken from `tgt_ds` (the two-part
|
||||||
|
libref.dataset). This ensures the "true" table and column
|
||||||
|
labels are used rather than any that may have been stripped
|
||||||
|
during WORK subsetting.
|
||||||
|
@li DD_SHORTDESC / DD_LONGDESC - taken from `mpe_datadictionary`
|
||||||
|
where DD_SOURCE contains the `LIB.DSN` reference and DD_TYPE is
|
||||||
|
`COLUMN`.
|
||||||
|
|
||||||
|
If `tgt_ds` is not supplied it defaults to `source`.
|
||||||
|
|
||||||
|
@param [in] type The type of labels to fetch (only COLUMNS is
|
||||||
|
currently supported).
|
||||||
|
@param [in] source The dataset from which column NAMES are read.
|
||||||
|
@param [in] tgt_ds= (&source) The two-part libref.dataset from which
|
||||||
|
the table/column LABELS are read, and used when joining to
|
||||||
|
`mpe_datadictionary` for short/long descriptions.
|
||||||
|
@param [out] outds= (mpe_getlabels) The output dataset with columns
|
||||||
|
`name`, `memlabel`, `desc`, `longdesc`.
|
||||||
|
|
||||||
<h4> SAS Macros </h4>
|
<h4> SAS Macros </h4>
|
||||||
@li mf_getuniquename.sas
|
@li mf_getuniquename.sas
|
||||||
@@ -13,26 +35,40 @@
|
|||||||
4GL Apps Ltd.
|
4GL Apps Ltd.
|
||||||
**/
|
**/
|
||||||
|
|
||||||
%macro mpe_getlabels(type,source,outds=mpe_getlabels);
|
%macro mpe_getlabels(type,source,tgt_ds=,outds=mpe_getlabels);
|
||||||
%local tmpds;
|
%local src_cols tgt_cols;
|
||||||
|
|
||||||
|
%if %superq(tgt_ds)= %then %let tgt_ds=&source;
|
||||||
|
|
||||||
%if &type=COLUMNS %then %do;
|
%if &type=COLUMNS %then %do;
|
||||||
%let tmpds=%mf_getuniquename();
|
%let src_cols=%mf_getuniquename();
|
||||||
|
%let tgt_cols=%mf_getuniquename();
|
||||||
|
|
||||||
|
/* NAMES from the (potentially filtered) source dataset */
|
||||||
proc contents noprint data=&source
|
proc contents noprint data=&source
|
||||||
out=&tmpds(keep=name memlabel label);
|
out=&src_cols(keep=name);
|
||||||
run;
|
run;
|
||||||
proc sql ;
|
|
||||||
|
/* LABELS from the underlying target dataset */
|
||||||
|
proc contents noprint data=&tgt_ds
|
||||||
|
out=&tgt_cols(keep=name memlabel label);
|
||||||
|
run;
|
||||||
|
|
||||||
|
proc sql;
|
||||||
create table &outds as
|
create table &outds as
|
||||||
select upcase(a.name) as name
|
select upcase(a.name) as name
|
||||||
,a.memlabel
|
,b.memlabel
|
||||||
,coalesce(b.dd_shortdesc,a.label) as desc
|
,coalesce(c.dd_shortdesc,b.label) as desc
|
||||||
,b.dd_longdesc as longdesc
|
,c.dd_longdesc as longdesc
|
||||||
from &tmpds a
|
from &src_cols a
|
||||||
|
inner join &tgt_cols b
|
||||||
|
on upcase(a.name)=upcase(b.name)
|
||||||
left join &mpelib..mpe_datadictionary
|
left join &mpelib..mpe_datadictionary
|
||||||
(where=(&dc_dttmtfmt. < tx_to
|
(where=(&dc_dttmtfmt. < tx_to
|
||||||
and dd_source ? %upcase("&source")
|
and dd_source ? %upcase("&tgt_ds")
|
||||||
and dd_type='COLUMN')) b
|
and dd_type='COLUMN')) c
|
||||||
on scan(b.dd_source,-1,'.')=upcase(a.name);
|
on scan(c.dd_source,-1,'.')=upcase(a.name);
|
||||||
|
quit;
|
||||||
%end;
|
%end;
|
||||||
|
|
||||||
%mend mpe_getlabels;
|
%mend mpe_getlabels;
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
/**
|
||||||
|
@file
|
||||||
|
@brief Testing mpe_getlabels macro
|
||||||
|
@details Verifies that the output dataset combines:
|
||||||
|
@li NAME from `source`
|
||||||
|
@li LABEL / MEMLABEL from `tgt_ds`
|
||||||
|
@li DD_SHORTDESC / DD_LONGDESC from MPE_DATADICTIONARY where DD_SOURCE
|
||||||
|
contains the `LIB.DSN` reference and DD_TYPE='COLUMN'.
|
||||||
|
|
||||||
|
Scenarios covered:
|
||||||
|
|
||||||
|
1. `source` is a two-part `LIB.DSN` - labels and DD entries are
|
||||||
|
joined directly.
|
||||||
|
2. `source` is a WORK subset without `tgt_ds` - DD entries do not
|
||||||
|
match.
|
||||||
|
3. `source` is a WORK subset with `tgt_ds` set to the underlying
|
||||||
|
`LIB.DSN` - labels come from the target and DD entries match.
|
||||||
|
4. `source` is a WORK subset that contains only a *subset* of the
|
||||||
|
target columns - the output contains only those columns while
|
||||||
|
labels still come from the target.
|
||||||
|
|
||||||
|
<h4> SAS Macros </h4>
|
||||||
|
@li mf_getuniquename.sas
|
||||||
|
@li mf_nobs.sas
|
||||||
|
@li mp_assert.sas
|
||||||
|
@li mp_assertdsobs.sas
|
||||||
|
@li mp_assertscope.sas
|
||||||
|
@li mpe_getlabels.sas
|
||||||
|
|
||||||
|
@author 4GL Apps Ltd
|
||||||
|
@copyright 4GL Apps Ltd. This code may only be used within Data Controller
|
||||||
|
and may not be re-distributed or re-sold without the express permission of
|
||||||
|
4GL Apps Ltd.
|
||||||
|
|
||||||
|
**/
|
||||||
|
|
||||||
|
/* teardown - remove any seeded rows */
|
||||||
|
proc sql;
|
||||||
|
delete from &mpelib..mpe_datadictionary
|
||||||
|
where dd_source in (
|
||||||
|
"&mpelib..MPE_X_TEST.PRIMARY_KEY_FIELD",
|
||||||
|
"&mpelib..MPE_X_TEST.SOME_CHAR"
|
||||||
|
);
|
||||||
|
quit;
|
||||||
|
/* seed MPE_DATADICTIONARY with column-level entries keyed on
|
||||||
|
LIB.DSNAME.COLUMN. &mpelib..MPE_X_TEST is created by testsetup.sas. */
|
||||||
|
proc sql;
|
||||||
|
insert into &mpelib..mpe_datadictionary set
|
||||||
|
tx_from=0
|
||||||
|
,tx_to='31DEC5999:23:59:59'dt
|
||||||
|
,dd_type='COLUMN'
|
||||||
|
,dd_source="&mpelib..MPE_X_TEST.PRIMARY_KEY_FIELD"
|
||||||
|
,dd_shortdesc='SHORT PK DESCRIPTION'
|
||||||
|
,dd_longdesc='LONG PK DESCRIPTION'
|
||||||
|
,dd_owner=''
|
||||||
|
,dd_responsible=''
|
||||||
|
,dd_sensitivity='';
|
||||||
|
insert into &mpelib..mpe_datadictionary set
|
||||||
|
tx_from=0
|
||||||
|
,tx_to='31DEC5999:23:59:59'dt
|
||||||
|
,dd_type='COLUMN'
|
||||||
|
,dd_source="&mpelib..MPE_X_TEST.SOME_CHAR"
|
||||||
|
,dd_shortdesc='SHORT CHAR DESCRIPTION'
|
||||||
|
,dd_longdesc='LONG CHAR DESCRIPTION'
|
||||||
|
,dd_owner=''
|
||||||
|
,dd_responsible=''
|
||||||
|
,dd_sensitivity='';
|
||||||
|
quit;
|
||||||
|
|
||||||
|
/* Test 1 - two-part libref.dataset as source */
|
||||||
|
%mp_assertscope(SNAPSHOT)
|
||||||
|
%mpe_getlabels(COLUMNS,&mpelib..MPE_X_TEST,outds=work.test1)
|
||||||
|
%mp_assertscope(COMPARE,
|
||||||
|
desc=Checking macro variables against previous snapshot
|
||||||
|
)
|
||||||
|
|
||||||
|
data _null_;
|
||||||
|
set work.test1;
|
||||||
|
putlog (_all_)(=);
|
||||||
|
run;
|
||||||
|
|
||||||
|
%mp_assertdsobs(work.test1,
|
||||||
|
desc=Test 1 - output dataset contains rows (one per column),
|
||||||
|
test=ATLEAST 1,
|
||||||
|
outds=work.test_results
|
||||||
|
)
|
||||||
|
|
||||||
|
%let shortdesc_hit=0;
|
||||||
|
%let longdesc_hit=0;
|
||||||
|
data _null_;
|
||||||
|
set work.test1;
|
||||||
|
where upcase(name)='PRIMARY_KEY_FIELD';
|
||||||
|
if desc='SHORT PK DESCRIPTION' then call symputx('shortdesc_hit',1);
|
||||||
|
if longdesc='LONG PK DESCRIPTION' then call symputx('longdesc_hit',1);
|
||||||
|
run;
|
||||||
|
|
||||||
|
%mp_assert(
|
||||||
|
iftrue=(&shortdesc_hit=1),
|
||||||
|
desc=Test 1 - DD_SHORTDESC returned as desc,
|
||||||
|
outds=work.test_results
|
||||||
|
)
|
||||||
|
%mp_assert(
|
||||||
|
iftrue=(&longdesc_hit=1),
|
||||||
|
desc=Test 1 - DD_LONGDESC returned as longdesc,
|
||||||
|
outds=work.test_results
|
||||||
|
)
|
||||||
|
|
||||||
|
/* Test 2 - WORK subset as source, no tgt_ds. DD_SOURCE values do not
|
||||||
|
contain `WORK.SASDATA1`, so DD entries are not applied and desc
|
||||||
|
falls back to whatever LABEL exists on the WORK dataset. */
|
||||||
|
data work.sasdata1;
|
||||||
|
set &mpelib..mpe_x_test;
|
||||||
|
run;
|
||||||
|
|
||||||
|
%mpe_getlabels(COLUMNS,work.sasdata1,outds=work.test2)
|
||||||
|
|
||||||
|
data _null_;
|
||||||
|
set work.test2;
|
||||||
|
putlog (_all_)(=);
|
||||||
|
run;
|
||||||
|
|
||||||
|
%let shortdesc_hit2=0;
|
||||||
|
data _null_;
|
||||||
|
set work.test2;
|
||||||
|
where upcase(name)='PRIMARY_KEY_FIELD';
|
||||||
|
if desc='SHORT PK DESCRIPTION' then call symputx('shortdesc_hit2',1);
|
||||||
|
run;
|
||||||
|
|
||||||
|
%mp_assert(
|
||||||
|
iftrue=(&shortdesc_hit2=0),
|
||||||
|
desc=Test 2 - Without tgt_ds a WORK source does not pick up DD_SHORTDESC,
|
||||||
|
outds=work.test_results
|
||||||
|
)
|
||||||
|
|
||||||
|
/* Test 3 - WORK subset as source with tgt_ds pointing at the
|
||||||
|
underlying LIB.DSN. Column names are taken from the WORK subset,
|
||||||
|
labels come from the target, DD entries are applied. */
|
||||||
|
%mpe_getlabels(COLUMNS,work.sasdata1,tgt_ds=&mpelib..MPE_X_TEST,outds=work.test3)
|
||||||
|
|
||||||
|
data _null_;
|
||||||
|
set work.test3;
|
||||||
|
putlog (_all_)(=);
|
||||||
|
run;
|
||||||
|
|
||||||
|
%mp_assertdsobs(work.test3,
|
||||||
|
desc=Test 3 - output dataset contains rows,
|
||||||
|
test=ATLEAST 1,
|
||||||
|
outds=work.test_results
|
||||||
|
)
|
||||||
|
|
||||||
|
%let shortdesc_hit3=0;
|
||||||
|
%let longdesc_hit3=0;
|
||||||
|
data _null_;
|
||||||
|
set work.test3;
|
||||||
|
where upcase(name)='PRIMARY_KEY_FIELD';
|
||||||
|
if desc='SHORT PK DESCRIPTION' then call symputx('shortdesc_hit3',1);
|
||||||
|
if longdesc='LONG PK DESCRIPTION' then call symputx('longdesc_hit3',1);
|
||||||
|
run;
|
||||||
|
|
||||||
|
%mp_assert(
|
||||||
|
iftrue=(&shortdesc_hit3=1),
|
||||||
|
desc=Test 3 - DD_SHORTDESC returned as desc when tgt_ds is supplied,
|
||||||
|
outds=work.test_results
|
||||||
|
)
|
||||||
|
%mp_assert(
|
||||||
|
iftrue=(&longdesc_hit3=1),
|
||||||
|
desc=Test 3 - DD_LONGDESC returned as longdesc when tgt_ds is supplied,
|
||||||
|
outds=work.test_results
|
||||||
|
)
|
||||||
|
|
||||||
|
/* Test 4 - WORK source with a strict subset of the target columns.
|
||||||
|
The output should contain only the columns present in `source`,
|
||||||
|
but labels still come from `tgt_ds`. */
|
||||||
|
data work.sasdata2;
|
||||||
|
set &mpelib..mpe_x_test (keep=primary_key_field);
|
||||||
|
run;
|
||||||
|
|
||||||
|
%mpe_getlabels(COLUMNS,work.sasdata2,tgt_ds=&mpelib..MPE_X_TEST,outds=work.test4)
|
||||||
|
|
||||||
|
data _null_;
|
||||||
|
set work.test4;
|
||||||
|
putlog (_all_)(=);
|
||||||
|
run;
|
||||||
|
|
||||||
|
%mp_assertdsobs(work.test4,
|
||||||
|
desc=Test 4 - output contains exactly one row (matching the source subset),
|
||||||
|
test=EQUALS 1,
|
||||||
|
outds=work.test_results
|
||||||
|
)
|
||||||
|
|
||||||
|
%let shortdesc_hit4=0;
|
||||||
|
data _null_;
|
||||||
|
set work.test4;
|
||||||
|
where upcase(name)='PRIMARY_KEY_FIELD';
|
||||||
|
if desc='SHORT PK DESCRIPTION' then call symputx('shortdesc_hit4',1);
|
||||||
|
run;
|
||||||
|
|
||||||
|
%mp_assert(
|
||||||
|
iftrue=(&shortdesc_hit4=1),
|
||||||
|
desc=Test 4 - DD_SHORTDESC returned for the retained column,
|
||||||
|
outds=work.test_results
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -823,7 +823,7 @@ insert into &lib..mpe_selectbox set
|
|||||||
,select_lib="&lib"
|
,select_lib="&lib"
|
||||||
,select_ds="MPE_VALIDATIONS"
|
,select_ds="MPE_VALIDATIONS"
|
||||||
,base_column="RULE_TYPE"
|
,base_column="RULE_TYPE"
|
||||||
,selectbox_value="MINVAL"
|
,selectbox_value="HARDSELECT"
|
||||||
,selectbox_order=2
|
,selectbox_order=2
|
||||||
,ver_to_dttm='31DEC5999:23:59:59'dt;
|
,ver_to_dttm='31DEC5999:23:59:59'dt;
|
||||||
insert into &lib..mpe_selectbox set
|
insert into &lib..mpe_selectbox set
|
||||||
@@ -832,7 +832,7 @@ insert into &lib..mpe_selectbox set
|
|||||||
,select_lib="&lib"
|
,select_lib="&lib"
|
||||||
,select_ds="MPE_VALIDATIONS"
|
,select_ds="MPE_VALIDATIONS"
|
||||||
,base_column="RULE_TYPE"
|
,base_column="RULE_TYPE"
|
||||||
,selectbox_value="MAXVAL"
|
,selectbox_value="HARDSELECT_HOOK"
|
||||||
,selectbox_order=3
|
,selectbox_order=3
|
||||||
,ver_to_dttm='31DEC5999:23:59:59'dt;
|
,ver_to_dttm='31DEC5999:23:59:59'dt;
|
||||||
insert into &lib..mpe_selectbox set
|
insert into &lib..mpe_selectbox set
|
||||||
@@ -841,7 +841,7 @@ insert into &lib..mpe_selectbox set
|
|||||||
,select_lib="&lib"
|
,select_lib="&lib"
|
||||||
,select_ds="MPE_VALIDATIONS"
|
,select_ds="MPE_VALIDATIONS"
|
||||||
,base_column="RULE_TYPE"
|
,base_column="RULE_TYPE"
|
||||||
,selectbox_value="NOTNULL"
|
,selectbox_value="HIDDEN"
|
||||||
,selectbox_order=4
|
,selectbox_order=4
|
||||||
,ver_to_dttm='31DEC5999:23:59:59'dt;
|
,ver_to_dttm='31DEC5999:23:59:59'dt;
|
||||||
insert into &lib..mpe_selectbox set
|
insert into &lib..mpe_selectbox set
|
||||||
@@ -850,7 +850,7 @@ insert into &lib..mpe_selectbox set
|
|||||||
,select_lib="&lib"
|
,select_lib="&lib"
|
||||||
,select_ds="MPE_VALIDATIONS"
|
,select_ds="MPE_VALIDATIONS"
|
||||||
,base_column="RULE_TYPE"
|
,base_column="RULE_TYPE"
|
||||||
,selectbox_value="HARDSELECT"
|
,selectbox_value="MAXVAL"
|
||||||
,selectbox_order=5
|
,selectbox_order=5
|
||||||
,ver_to_dttm='31DEC5999:23:59:59'dt;
|
,ver_to_dttm='31DEC5999:23:59:59'dt;
|
||||||
insert into &lib..mpe_selectbox set
|
insert into &lib..mpe_selectbox set
|
||||||
@@ -859,16 +859,16 @@ insert into &lib..mpe_selectbox set
|
|||||||
,select_lib="&lib"
|
,select_lib="&lib"
|
||||||
,select_ds="MPE_VALIDATIONS"
|
,select_ds="MPE_VALIDATIONS"
|
||||||
,base_column="RULE_TYPE"
|
,base_column="RULE_TYPE"
|
||||||
,selectbox_value="HARDSELECT_HOOK"
|
,selectbox_value="MINVAL"
|
||||||
,selectbox_order=6
|
,selectbox_order=6
|
||||||
,ver_to_dttm='31DEC5999:23:59:59'dt;
|
,ver_to_dttm='31DEC5999:23:59:59'dt;
|
||||||
insert into &lib..mpe_selectbox set
|
insert into &lib..mpe_selectbox set
|
||||||
selectbox_rk=%mf_increment(rk)
|
selectbox_rk=%mf_increment(rk)
|
||||||
,ver_from_dttm=0
|
,ver_from_dttm=0
|
||||||
,select_lib="&lib"
|
,select_lib="&lib"
|
||||||
,select_ds="MPE_VALIDATIONS"
|
,select_ds="MPE_VALIDATIONS"
|
||||||
,base_column="RULE_TYPE"
|
,base_column="RULE_TYPE"
|
||||||
,selectbox_value="SOFTSELECT"
|
,selectbox_value="NOTNULL"
|
||||||
,selectbox_order=7
|
,selectbox_order=7
|
||||||
,ver_to_dttm='31DEC5999:23:59:59'dt;
|
,ver_to_dttm='31DEC5999:23:59:59'dt;
|
||||||
insert into &lib..mpe_selectbox set
|
insert into &lib..mpe_selectbox set
|
||||||
@@ -877,9 +877,45 @@ insert into &lib..mpe_selectbox set
|
|||||||
,select_lib="&lib"
|
,select_lib="&lib"
|
||||||
,select_ds="MPE_VALIDATIONS"
|
,select_ds="MPE_VALIDATIONS"
|
||||||
,base_column="RULE_TYPE"
|
,base_column="RULE_TYPE"
|
||||||
,selectbox_value="SOFTSELECT_HOOK"
|
,selectbox_value="NUMBER_FORMAT"
|
||||||
,selectbox_order=8
|
,selectbox_order=8
|
||||||
,ver_to_dttm='31DEC5999:23:59:59'dt;
|
,ver_to_dttm='31DEC5999:23:59:59'dt;
|
||||||
|
insert into &lib..mpe_selectbox set
|
||||||
|
selectbox_rk=%mf_increment(rk)
|
||||||
|
,ver_from_dttm=0
|
||||||
|
,select_lib="&lib"
|
||||||
|
,select_ds="MPE_VALIDATIONS"
|
||||||
|
,base_column="RULE_TYPE"
|
||||||
|
,selectbox_value="READONLY"
|
||||||
|
,selectbox_order=9
|
||||||
|
,ver_to_dttm='31DEC5999:23:59:59'dt;
|
||||||
|
insert into &lib..mpe_selectbox set
|
||||||
|
selectbox_rk=%mf_increment(rk)
|
||||||
|
,ver_from_dttm=0
|
||||||
|
,select_lib="&lib"
|
||||||
|
,select_ds="MPE_VALIDATIONS"
|
||||||
|
,base_column="RULE_TYPE"
|
||||||
|
,selectbox_value="ROUND"
|
||||||
|
,selectbox_order=10
|
||||||
|
,ver_to_dttm='31DEC5999:23:59:59'dt;
|
||||||
|
insert into &lib..mpe_selectbox set
|
||||||
|
selectbox_rk=%mf_increment(rk)
|
||||||
|
,ver_from_dttm=0
|
||||||
|
,select_lib="&lib"
|
||||||
|
,select_ds="MPE_VALIDATIONS"
|
||||||
|
,base_column="RULE_TYPE"
|
||||||
|
,selectbox_value="SOFTSELECT"
|
||||||
|
,selectbox_order=11
|
||||||
|
,ver_to_dttm='31DEC5999:23:59:59'dt;
|
||||||
|
insert into &lib..mpe_selectbox set
|
||||||
|
selectbox_rk=%mf_increment(rk)
|
||||||
|
,ver_from_dttm=0
|
||||||
|
,select_lib="&lib"
|
||||||
|
,select_ds="MPE_VALIDATIONS"
|
||||||
|
,base_column="RULE_TYPE"
|
||||||
|
,selectbox_value="SOFTSELECT_HOOK"
|
||||||
|
,selectbox_order=12
|
||||||
|
,ver_to_dttm='31DEC5999:23:59:59'dt;
|
||||||
insert into &lib..mpe_selectbox set
|
insert into &lib..mpe_selectbox set
|
||||||
selectbox_rk=%mf_increment(rk)
|
selectbox_rk=%mf_increment(rk)
|
||||||
,ver_from_dttm=0
|
,ver_from_dttm=0
|
||||||
|
|||||||
@@ -280,7 +280,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "server-mihajlo",
|
"name": "server-mihajlo",
|
||||||
"serverUrl": "https://sas9.4gl.io",
|
"serverUrl": "https://sas.4gl.io",
|
||||||
"serverType": "SASJS",
|
"serverType": "SASJS",
|
||||||
"httpsAgentOptions": {
|
"httpsAgentOptions": {
|
||||||
"rejectUnauthorized": false,
|
"rejectUnauthorized": false,
|
||||||
|
|||||||
@@ -270,15 +270,14 @@ data _null_;
|
|||||||
file vldtr ;
|
file vldtr ;
|
||||||
put 'proc sql;';
|
put 'proc sql;';
|
||||||
put 'create table work.vals as';
|
put 'create table work.vals as';
|
||||||
put ' select distinct ORIGIN as display_value,';
|
put ' select distinct ORIGIN as raw_value';
|
||||||
put ' ORIGIN as raw_value';
|
|
||||||
put " from &demolib..COUNTRIES";
|
put " from &demolib..COUNTRIES";
|
||||||
put ' order by 1;';
|
put ' order by 1;';
|
||||||
put 'data work.DYNAMIC_VALUES; set work.vals;display_index=_n_;run;';
|
put 'data work.DYNAMIC_VALUES; set work.vals;display_index=_n_;run;';
|
||||||
put ' ';
|
put ' ';
|
||||||
put 'proc sql;';
|
put 'proc sql;';
|
||||||
put 'create table work.dev as ';
|
put 'create table work.dev as ';
|
||||||
put ' select a.display_index,b.country as display_value';
|
put ' select a.display_index, a.raw_value';
|
||||||
put ' from work.DYNAMIC_VALUES as a';
|
put ' from work.DYNAMIC_VALUES as a';
|
||||||
put " left join &demolib..countries as b";
|
put " left join &demolib..countries as b";
|
||||||
put " on a.raw_value=b.origin";
|
put " on a.raw_value=b.origin";
|
||||||
@@ -286,7 +285,7 @@ data _null_;
|
|||||||
put 'data work.DYNAMIC_EXTENDED_VALUES; set work.dev;by display_index;';
|
put 'data work.DYNAMIC_EXTENDED_VALUES; set work.dev;by display_index;';
|
||||||
put ' EXTRA_COL_NAME="COUNTRY";';
|
put ' EXTRA_COL_NAME="COUNTRY";';
|
||||||
put ' DISPLAY_TYPE="C";';
|
put ' DISPLAY_TYPE="C";';
|
||||||
put ' RAW_VALUE_CHAR=DISPLAY_VALUE;';
|
put ' RAW_VALUE_CHAR=raw_value;';
|
||||||
put ' RAW_VALUE_NUM=.;';
|
put ' RAW_VALUE_NUM=.;';
|
||||||
put ' if first.display_index then forced_value=1;';
|
put ' if first.display_index then forced_value=1;';
|
||||||
put 'run;';
|
put 'run;';
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
@li memlabel
|
@li memlabel
|
||||||
@li desc- augmented with MPE_DATADICTIONARY if exists, else label
|
@li desc- augmented with MPE_DATADICTIONARY if exists, else label
|
||||||
@li longdesc - from MPE_DATADICTIONARY
|
@li longdesc - from MPE_DATADICTIONARY
|
||||||
|
@li coltype - front-end column formatting spec
|
||||||
|
|
||||||
|
|
||||||
<h5> maxvarlengths </h5>
|
<h5> maxvarlengths </h5>
|
||||||
@@ -333,12 +334,11 @@ select upcase(loadtype)
|
|||||||
)
|
)
|
||||||
|
|
||||||
%global jsdttmvars jsdtvars jstmvars;
|
%global jsdttmvars jsdtvars jstmvars;
|
||||||
data _null_;
|
data vars4;
|
||||||
set vars3 end=last;
|
set vars3;
|
||||||
if _n_>1 then comma=',';
|
|
||||||
length coltype $500.;
|
length coltype $500.;
|
||||||
format=upcase(format);
|
format=upcase(format);
|
||||||
coltype=cats(comma,'{"data":"',name,'"');
|
coltype=cats('{"data":"',name,'"');
|
||||||
if ctrlOptions ne '' then
|
if ctrlOptions ne '' then
|
||||||
colType=cats(coltype,',"type":"dropdown","source":',ctrlOptions,"}");
|
colType=cats(coltype,',"type":"dropdown","source":',ctrlOptions,"}");
|
||||||
else if type='num' then do;
|
else if type='num' then do;
|
||||||
@@ -372,10 +372,7 @@ select upcase(loadtype)
|
|||||||
end;
|
end;
|
||||||
end;
|
end;
|
||||||
else colType=cats(coltype,'}');
|
else colType=cats(coltype,'}');
|
||||||
length concatcoltype $32767;
|
output;
|
||||||
retain concatcoltype;
|
|
||||||
concatcoltype=cats(concatcoltype,coltype);
|
|
||||||
if last then call symputx('colType',strip(concatcoltype),'g');
|
|
||||||
putlog (_all_)(=);
|
putlog (_all_)(=);
|
||||||
run;
|
run;
|
||||||
|
|
||||||
@@ -480,8 +477,9 @@ select upcase(loadtype)
|
|||||||
,outmeta=work.cls_rules
|
,outmeta=work.cls_rules
|
||||||
)
|
)
|
||||||
|
|
||||||
/* get labels */
|
/* get labels - pull column names from the CLS-filtered WORK dataset but
|
||||||
%mpe_getlabels(COLUMNS,sasdata1,outds=spec)
|
join to mpe_datadictionary using the underlying LIB.DSN reference */
|
||||||
|
%mpe_getlabels(COLUMNS,sasdata1,tgt_ds=&libds,outds=spec)
|
||||||
%mp_abort(iftrue= (&syscc ne 0)
|
%mp_abort(iftrue= (&syscc ne 0)
|
||||||
,mac=&_program
|
,mac=&_program
|
||||||
,msg=%str(syscc=&syscc extracting spec info)
|
,msg=%str(syscc=&syscc extracting spec info)
|
||||||
@@ -495,7 +493,7 @@ proc sql;
|
|||||||
create table work.cols as
|
create table work.cols as
|
||||||
select a.NAME
|
select a.NAME
|
||||||
,a.VARNUM
|
,a.VARNUM
|
||||||
,a.LABEL
|
,c.desc as LABEL
|
||||||
,a.FMTNAME
|
,a.FMTNAME
|
||||||
,a.DDTYPE
|
,a.DDTYPE
|
||||||
,case b.cls_hide
|
,case b.cls_hide
|
||||||
@@ -503,13 +501,15 @@ create table work.cols as
|
|||||||
when 0 then 'EDIT'
|
when 0 then 'EDIT'
|
||||||
else 'READ' end as CLS_RULE
|
else 'READ' end as CLS_RULE
|
||||||
,c.memlabel
|
,c.memlabel
|
||||||
,c.desc
|
|
||||||
,c.longdesc
|
,c.longdesc
|
||||||
|
,d.colType
|
||||||
from work.cols1 a
|
from work.cols1 a
|
||||||
left join work.cls_rules b
|
left join work.cls_rules b
|
||||||
on a.NAME=b.CLS_VARIABLE_NM
|
on a.NAME=b.CLS_VARIABLE_NM
|
||||||
left join work.spec c
|
left join work.spec c
|
||||||
on a.NAME=c.NAME;
|
on a.NAME=c.NAME
|
||||||
|
left join work.vars4 d
|
||||||
|
on a.NAME=d.NAME;
|
||||||
|
|
||||||
proc sql;
|
proc sql;
|
||||||
create table approvers as select distinct membername as personname
|
create table approvers as select distinct membername as personname
|
||||||
@@ -550,8 +550,6 @@ data sasparams;
|
|||||||
dtvars=compbl("&jsdtvars");
|
dtvars=compbl("&jsdtvars");
|
||||||
dttmvars=compbl("&jsdttmvars");
|
dttmvars=compbl("&jsdttmvars");
|
||||||
tmvars=compbl("&jstmvars");
|
tmvars=compbl("&jstmvars");
|
||||||
length coltype $32000;
|
|
||||||
coltype=symget('coltype');
|
|
||||||
loadtype=symget('loadtype');
|
loadtype=symget('loadtype');
|
||||||
if trim(symget('rk_underlying')) ne '' then rk_flag=1;
|
if trim(symget('rk_underlying')) ne '' then rk_flag=1;
|
||||||
else rk_flag=0;
|
else rk_flag=0;
|
||||||
|
|||||||
@@ -22,8 +22,7 @@
|
|||||||
|
|
||||||
<h5>DYNAMIC_VALUES</h5>
|
<h5>DYNAMIC_VALUES</h5>
|
||||||
The RAW_VALUE column may be charactor or numeric. If DISPLAY_INDEX is not
|
The RAW_VALUE column may be charactor or numeric. If DISPLAY_INDEX is not
|
||||||
provided, it is added automatically. A DISPLAY_VALUE column may be supplied
|
provided, it is added automatically.
|
||||||
by the hook but is ignored - only DISPLAY_INDEX and RAW_VALUE are returned.
|
|
||||||
|
|
||||||
|DISPLAY_INDEX:best.|RAW_VALUE|
|
|DISPLAY_INDEX:best.|RAW_VALUE|
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -38,9 +37,6 @@
|
|||||||
Should be used sparingly! The use of large tables here can slow down the
|
Should be used sparingly! The use of large tables here can slow down the
|
||||||
browser.
|
browser.
|
||||||
|
|
||||||
A DISPLAY_VALUE column may be supplied by the hook but is ignored - it is not
|
|
||||||
returned to the client.
|
|
||||||
|
|
||||||
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|
|
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|1|DISCOUNT_RT|N|0.5||
|
|1|DISCOUNT_RT|N|0.5||
|
||||||
@@ -171,29 +167,26 @@ run;
|
|||||||
%mp_include(sascode)
|
%mp_include(sascode)
|
||||||
%mp_abort(mode=INCLUDE)
|
%mp_abort(mode=INCLUDE)
|
||||||
|
|
||||||
/* ensure that the DISPLAY_INDEX variable exists. DISPLAY_VALUE is accepted
|
/* ensure that the DISPLAY_INDEX variable exists. */
|
||||||
from the hook for backwards compatibility but is no longer sent to the client */
|
|
||||||
data work.dynamic_values;
|
data work.dynamic_values;
|
||||||
length DISPLAY_INDEX 8 DISPLAY_VALUE $32767;
|
length DISPLAY_INDEX 8;
|
||||||
if _n_=1 then call missing(of _all_);
|
if _n_=1 then call missing(of _all_);
|
||||||
set work.dynamic_values;
|
set work.dynamic_values;
|
||||||
display_index=coalesce(display_index,_n_);
|
display_index=coalesce(display_index,_n_);
|
||||||
keep DISPLAY_INDEX RAW_VALUE;
|
keep DISPLAY_INDEX RAW_VALUE;
|
||||||
run;
|
run;
|
||||||
|
|
||||||
/* ensure that work.dynamic_extended_values exists with correct types.
|
/* ensure that work.dynamic_extended_values exists with correct types.*/
|
||||||
DISPLAY_VALUE is dropped - it is not consumed by the client */
|
|
||||||
data work.dynamic_extended_values;
|
data work.dynamic_extended_values;
|
||||||
length DISPLAY_INDEX 8 EXTRA_COL_NAME $32 DISPLAY_VALUE $5000 DISPLAY_TYPE $1
|
length DISPLAY_INDEX 8 EXTRA_COL_NAME $32 DISPLAY_TYPE $1
|
||||||
RAW_VALUE_NUM 8 RAW_VALUE_CHAR $5000 FORCED_VALUE 8;
|
RAW_VALUE_NUM 8 RAW_VALUE_CHAR $5000 FORCED_VALUE 8;
|
||||||
if _n_=1 then call missing(of _all_);
|
if _n_=1 then call missing(of _all_);
|
||||||
set work.dynamic_extended_values;
|
set work.dynamic_extended_values;
|
||||||
drop DISPLAY_VALUE;
|
|
||||||
run;
|
run;
|
||||||
|
|
||||||
%webout(OPEN)
|
%webout(OPEN)
|
||||||
%webout(ARR,dynamic_values,fmt=N)
|
%webout(OBJ,dynamic_values,fmt=N)
|
||||||
%webout(ARR,dynamic_extended_values,fmt=N)
|
%webout(OBJ,dynamic_extended_values,fmt=N)
|
||||||
%webout(CLOSE)
|
%webout(CLOSE)
|
||||||
|
|
||||||
%mpeterm()
|
%mpeterm()
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ run;
|
|||||||
and the two array elements (DISPLAY_INDEX, RAW_VALUE)
|
and the two array elements (DISPLAY_INDEX, RAW_VALUE)
|
||||||
*/
|
*/
|
||||||
%mp_assertcols(work.DYNAMIC_VALUES,
|
%mp_assertcols(work.DYNAMIC_VALUES,
|
||||||
cols=element1 element2,
|
cols=display_index raw_value,
|
||||||
test=ALL,
|
test=ALL,
|
||||||
desc=Check two columns exist in DYNAMIC_VALUES
|
desc=Check two columns exist in DYNAMIC_VALUES
|
||||||
)
|
)
|
||||||
@@ -93,7 +93,7 @@ run;
|
|||||||
data work.check;
|
data work.check;
|
||||||
val="&dclib";
|
val="&dclib";
|
||||||
run;
|
run;
|
||||||
%mp_assertcolvals(work.dynamic_values.element2,
|
%mp_assertcolvals(work.dynamic_values.raw_value,
|
||||||
checkvals=work.check.val,
|
checkvals=work.check.val,
|
||||||
desc=DCLIB found in getdynamicgolvals RAW_VALUE response,
|
desc=DCLIB found in getdynamicgolvals RAW_VALUE response,
|
||||||
test=ANYVAL
|
test=ANYVAL
|
||||||
|
|||||||
@@ -71,8 +71,6 @@
|
|||||||
/* configure macvars */
|
/* configure macvars */
|
||||||
%global LIBDS FILTER_RK SEARCHVAL SEARCHTYPE FMT_IND;
|
%global LIBDS FILTER_RK SEARCHVAL SEARCHTYPE FMT_IND;
|
||||||
|
|
||||||
%let maxrows=250;
|
|
||||||
|
|
||||||
/* avoid code injection */
|
/* avoid code injection */
|
||||||
%let FMT_IND=0;
|
%let FMT_IND=0;
|
||||||
%let SEARCHTYPE=;
|
%let SEARCHTYPE=;
|
||||||
@@ -173,6 +171,13 @@ run;
|
|||||||
%global dsobs;
|
%global dsobs;
|
||||||
%let dsobs=0;
|
%let dsobs=0;
|
||||||
%macro x();
|
%macro x();
|
||||||
|
%if not %symexist(DC_MAXOBS_WEBVIEW) %then %do;
|
||||||
|
%put NOTE:;%put NOTE- DC_MAXOBS_WEBVIEW not found!;
|
||||||
|
%put NOTE- Please add to &mpelib..MPE_CONFIG table;
|
||||||
|
%put NOTE-;%put NOTE-;
|
||||||
|
%global DC_MAXOBS_WEBVIEW;
|
||||||
|
%let DC_MAXOBS_WEBVIEW=500;
|
||||||
|
%end;
|
||||||
%if &existds>0 %then %do;
|
%if &existds>0 %then %do;
|
||||||
|
|
||||||
%if &fmt_ind=1 %then %do;
|
%if &fmt_ind=1 %then %do;
|
||||||
@@ -234,7 +239,7 @@ run;
|
|||||||
data work.viewdata;
|
data work.viewdata;
|
||||||
set &libds;
|
set &libds;
|
||||||
where %inc filtref;;
|
where %inc filtref;;
|
||||||
if _n_>&maxrows then stop;
|
if _n_>&DC_MAXOBS_WEBVIEW then stop;
|
||||||
run;
|
run;
|
||||||
%if %mf_nobs(work.viewdata)=0 %then %do;
|
%if %mf_nobs(work.viewdata)=0 %then %do;
|
||||||
data work.viewdata;
|
data work.viewdata;
|
||||||
@@ -253,21 +258,21 @@ run;
|
|||||||
%mp_searchdata(lib=work
|
%mp_searchdata(lib=work
|
||||||
,ds=vwsearch
|
,ds=vwsearch
|
||||||
,string=%superq(searchval)
|
,string=%superq(searchval)
|
||||||
,outobs=&maxrows
|
,outobs=&DC_MAXOBS_WEBVIEW
|
||||||
)
|
)
|
||||||
%end;
|
%end;
|
||||||
%else %if %upcase(&searchtype)=NUM %then %do;
|
%else %if %upcase(&searchtype)=NUM %then %do;
|
||||||
%mp_searchdata(lib=work
|
%mp_searchdata(lib=work
|
||||||
,ds=vwsearch
|
,ds=vwsearch
|
||||||
,numval=%superq(searchval)
|
,numval=%superq(searchval)
|
||||||
,outobs=&maxrows
|
,outobs=&DC_MAXOBS_WEBVIEW
|
||||||
)
|
)
|
||||||
%end;
|
%end;
|
||||||
%if %mf_existds(libds=MPSEARCH.vwsearch) %then %do;
|
%if %mf_existds(libds=MPSEARCH.vwsearch) %then %do;
|
||||||
%let dsobs=%mf_nobs(MPSEARCH.vwsearch);
|
%let dsobs=%mf_nobs(MPSEARCH.vwsearch);
|
||||||
data viewdata;
|
data viewdata;
|
||||||
set MPSEARCH.vwsearch;
|
set MPSEARCH.vwsearch;
|
||||||
if _n_<&maxrows;
|
if _n_<&DC_MAXOBS_WEBVIEW;
|
||||||
run;
|
run;
|
||||||
%end;
|
%end;
|
||||||
%else %do;
|
%else %do;
|
||||||
@@ -347,7 +352,7 @@ data work.sasparams;
|
|||||||
PK_FIELDS=symget('PK_FIELDS');
|
PK_FIELDS=symget('PK_FIELDS');
|
||||||
nobs=&dsobs;
|
nobs=&dsobs;
|
||||||
vars=%mf_getvarcount(viewdata);
|
vars=%mf_getvarcount(viewdata);
|
||||||
maxrows=&maxrows;
|
maxrows=&DC_MAXOBS_WEBVIEW;
|
||||||
run;
|
run;
|
||||||
|
|
||||||
%mp_abort(iftrue= (&syscc ne 0)
|
%mp_abort(iftrue= (&syscc ne 0)
|
||||||
@@ -355,7 +360,19 @@ run;
|
|||||||
,msg=%str(syscc=&syscc)
|
,msg=%str(syscc=&syscc)
|
||||||
)
|
)
|
||||||
|
|
||||||
%mp_getcols(&libds, outds=cols)
|
%mp_getcols(&libds, outds=cols1)
|
||||||
|
proc sql;
|
||||||
|
create table cols(drop=srclabel) as
|
||||||
|
select a.*
|
||||||
|
,coalesce(b.dd_shortdesc,a.srclabel) as label
|
||||||
|
from cols1(rename=(label=srclabel)) a
|
||||||
|
left join &mpelib..mpe_datadictionary
|
||||||
|
(where=(&dc_dttmtfmt. < tx_to
|
||||||
|
and dd_source ? %upcase("&orig_libds")
|
||||||
|
and dd_type='COLUMN')) b
|
||||||
|
on scan(b.dd_source,-1,'.')=upcase(a.name);
|
||||||
|
quit;
|
||||||
|
|
||||||
|
|
||||||
%mpe_dsmeta(&orig_libds, outds=dsmeta)
|
%mpe_dsmeta(&orig_libds, outds=dsmeta)
|
||||||
|
|
||||||
|
|||||||
@@ -22,10 +22,10 @@
|
|||||||
The RAW_VALUE column may be charactor or numeric. If DISPLAY_INDEX is not
|
The RAW_VALUE column may be charactor or numeric. If DISPLAY_INDEX is not
|
||||||
provided, it is added automatically.
|
provided, it is added automatically.
|
||||||
|
|
||||||
|DISPLAY_INDEX:best.|DISPLAY_VALUE:$|RAW_VALUE|
|
|DISPLAY_INDEX:best.|RAW_VALUE|
|
||||||
|---|---|---|
|
|---|---|
|
||||||
|1|$77.43|77.43|
|
|1|77.43|
|
||||||
|2|$88.43|88.43|
|
|2|88.43|
|
||||||
|
|
||||||
<h5>DYNAMIC_EXTENDED_VALUES</h5>
|
<h5>DYNAMIC_EXTENDED_VALUES</h5>
|
||||||
This table is optional. If provided, it will map the DISPLAY_INDEX from the
|
This table is optional. If provided, it will map the DISPLAY_INDEX from the
|
||||||
@@ -35,17 +35,17 @@
|
|||||||
Should be used sparingly! The use of large tables here can slow down the
|
Should be used sparingly! The use of large tables here can slow down the
|
||||||
browser.
|
browser.
|
||||||
|
|
||||||
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_VALUE:$|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|
|
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|1|DISCOUNT_RT|"50%"|N|0.5||
|
|1|DISCOUNT_RT|N|0.5||
|
||||||
|1|DISCOUNT_RT|"40%"|N|0.4||
|
|1|DISCOUNT_RT|N|0.4||
|
||||||
|1|DISCOUNT_RT|"30%"|N|0.3||
|
|1|DISCOUNT_RT|N|0.3||
|
||||||
|1|CURRENCY_SYMBOL|"GBP"|C||"GBP"|
|
|1|CURRENCY_SYMBOL|C||"GBP"|
|
||||||
|1|CURRENCY_SYMBOL|"RSD"|C||"RSD"|
|
|1|CURRENCY_SYMBOL|C||"RSD"|
|
||||||
|2|DISCOUNT_RT|"50%"|N|0.5||
|
|2|DISCOUNT_RT|N|0.5||
|
||||||
|2|DISCOUNT_RT|"40%"|N|0.4||
|
|2|DISCOUNT_RT|N|0.4||
|
||||||
|2|CURRENCY_SYMBOL|"EUR"|C||"EUR"|
|
|2|CURRENCY_SYMBOL|C||"EUR"|
|
||||||
|2|CURRENCY_SYMBOL|"HKD"|C||"HKD"|
|
|2|CURRENCY_SYMBOL|C||"HKD"|
|
||||||
|
|
||||||
<h4> SAS Macros </h4>
|
<h4> SAS Macros </h4>
|
||||||
@li dc_assignlib.sas
|
@li dc_assignlib.sas
|
||||||
@@ -96,13 +96,13 @@ run;
|
|||||||
%dc_assignlib(READ,%scan(&tgtlibds,1,.))
|
%dc_assignlib(READ,%scan(&tgtlibds,1,.))
|
||||||
|
|
||||||
proc contents noprint data=&tgtlibds
|
proc contents noprint data=&tgtlibds
|
||||||
out=work.DYNAMIC_VALUES (keep=name rename=(name=display_value) );
|
out=work.DYNAMIC_VALUES (keep=name rename=(name=raw_value) );
|
||||||
run;
|
run;
|
||||||
|
|
||||||
data work.DYNAMIC_VALUES;
|
data work.DYNAMIC_VALUES;
|
||||||
set work.DYNAMIC_VALUES;
|
set work.DYNAMIC_VALUES;
|
||||||
raw_value=upcase(display_value);
|
|
||||||
format raw_value;
|
format raw_value;
|
||||||
|
raw_value=upcase(raw_value);
|
||||||
run;
|
run;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,10 +18,10 @@
|
|||||||
The RAW_VALUE column may be charactor or numeric. If DISPLAY_INDEX is not
|
The RAW_VALUE column may be charactor or numeric. If DISPLAY_INDEX is not
|
||||||
provided, it is added automatically.
|
provided, it is added automatically.
|
||||||
|
|
||||||
|DISPLAY_INDEX:best.|DISPLAY_VALUE:$|RAW_VALUE|
|
|DISPLAY_INDEX:best.|RAW_VALUE|
|
||||||
|---|---|---|
|
|---|---|
|
||||||
|1|$77.43|77.43|
|
|1|77.43|
|
||||||
|2|$88.43|88.43|
|
|2|88.43|
|
||||||
|
|
||||||
<h5>DYNAMIC_EXTENDED_VALUES</h5>
|
<h5>DYNAMIC_EXTENDED_VALUES</h5>
|
||||||
This table is optional. If provided, it will map the DISPLAY_INDEX from the
|
This table is optional. If provided, it will map the DISPLAY_INDEX from the
|
||||||
@@ -31,17 +31,17 @@
|
|||||||
Should be used sparingly! The use of large tables here can slow down the
|
Should be used sparingly! The use of large tables here can slow down the
|
||||||
browser.
|
browser.
|
||||||
|
|
||||||
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_VALUE:$|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|
|
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|
|
||||||
|---|---|---|
|
|---|---|---|---|---|
|
||||||
|1|DISCOUNT_RT|"50%"|N|0.5||
|
|1|DISCOUNT_RT|N|0.5||
|
||||||
|1|DISCOUNT_RT|"40%"|N|0.4||
|
|1|DISCOUNT_RT|N|0.4||
|
||||||
|1|DISCOUNT_RT|"30%"|N|0.3||
|
|1|DISCOUNT_RT|N|0.3||
|
||||||
|1|CURRENCY_SYMBOL|"GBP"|C||"GBP"|
|
|1|CURRENCY_SYMBOL|C||"GBP"|
|
||||||
|1|CURRENCY_SYMBOL|"RSD"|C||"RSD"|
|
|1|CURRENCY_SYMBOL|C||"RSD"|
|
||||||
|2|DISCOUNT_RT|"50%"|N|0.5||
|
|2|DISCOUNT_RT|N|0.5||
|
||||||
|2|DISCOUNT_RT|"40%"|N|0.4||
|
|2|DISCOUNT_RT|N|0.4||
|
||||||
|2|CURRENCY_SYMBOL|"EUR"|C||"EUR"|
|
|2|CURRENCY_SYMBOL|C||"EUR"|
|
||||||
|2|CURRENCY_SYMBOL|"HKD"|C||"HKD"|
|
|2|CURRENCY_SYMBOL|C||"HKD"|
|
||||||
|
|
||||||
<h4> SAS Macros </h4>
|
<h4> SAS Macros </h4>
|
||||||
@li dc_getlibs.sas
|
@li dc_getlibs.sas
|
||||||
@@ -56,8 +56,7 @@
|
|||||||
|
|
||||||
proc sql;
|
proc sql;
|
||||||
create table work.DYNAMIC_VALUES as
|
create table work.DYNAMIC_VALUES as
|
||||||
select distinct libraryname as display_value,
|
select distinct upcase(libraryref) as raw_value
|
||||||
upcase(libraryref) as raw_value
|
|
||||||
from work.mm_getLibs
|
from work.mm_getLibs
|
||||||
order by 1;
|
order by 1;
|
||||||
|
|
||||||
|
|||||||
@@ -18,10 +18,10 @@
|
|||||||
The RAW_VALUE column may be charactor or numeric. If DISPLAY_INDEX is not
|
The RAW_VALUE column may be charactor or numeric. If DISPLAY_INDEX is not
|
||||||
provided, it is added automatically.
|
provided, it is added automatically.
|
||||||
|
|
||||||
|DISPLAY_INDEX:best.|DISPLAY_VALUE:$|RAW_VALUE|
|
|DISPLAY_INDEX:best.|RAW_VALUE|
|
||||||
|---|---|---|
|
|---|---|
|
||||||
|1|$77.43|77.43|
|
|1|77.43|
|
||||||
|2|$88.43|88.43|
|
|2|88.43|
|
||||||
|
|
||||||
<h5>DYNAMIC_EXTENDED_VALUES</h5>
|
<h5>DYNAMIC_EXTENDED_VALUES</h5>
|
||||||
This table is optional. If provided, it will map the DISPLAY_INDEX from the
|
This table is optional. If provided, it will map the DISPLAY_INDEX from the
|
||||||
@@ -31,17 +31,17 @@
|
|||||||
Should be used sparingly! The use of large tables here can slow down the
|
Should be used sparingly! The use of large tables here can slow down the
|
||||||
browser.
|
browser.
|
||||||
|
|
||||||
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_VALUE:$|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|
|
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|1|DISCOUNT_RT|"50%"|N|0.5||
|
|1|DISCOUNT_RT|N|0.5||
|
||||||
|1|DISCOUNT_RT|"40%"|N|0.4||
|
|1|DISCOUNT_RT|N|0.4||
|
||||||
|1|DISCOUNT_RT|"30%"|N|0.3||
|
|1|DISCOUNT_RT|N|0.3||
|
||||||
|1|CURRENCY_SYMBOL|"GBP"|C||"GBP"|
|
|1|CURRENCY_SYMBOL|C||"GBP"|
|
||||||
|1|CURRENCY_SYMBOL|"RSD"|C||"RSD"|
|
|1|CURRENCY_SYMBOL|C||"RSD"|
|
||||||
|2|DISCOUNT_RT|"50%"|N|0.5||
|
|2|DISCOUNT_RT|N|0.5||
|
||||||
|2|DISCOUNT_RT|"40%"|N|0.4||
|
|2|DISCOUNT_RT|N|0.4||
|
||||||
|2|CURRENCY_SYMBOL|"EUR"|C||"EUR"|
|
|2|CURRENCY_SYMBOL|C||"EUR"|
|
||||||
|2|CURRENCY_SYMBOL|"HKD"|C||"HKD"|
|
|2|CURRENCY_SYMBOL|C||"HKD"|
|
||||||
|
|
||||||
|
|
||||||
**/
|
**/
|
||||||
@@ -49,8 +49,7 @@
|
|||||||
/* send back the raw and formatted values */
|
/* send back the raw and formatted values */
|
||||||
proc sql;
|
proc sql;
|
||||||
create table work.DYNAMIC_VALUES as
|
create table work.DYNAMIC_VALUES as
|
||||||
select distinct libref as display_value,
|
select distinct upcase(libref) as raw_value
|
||||||
upcase(libref) as raw_value
|
|
||||||
from &mpelib..mpe_tables
|
from &mpelib..mpe_tables
|
||||||
where &dc_dttmtfmt. < tx_to
|
where &dc_dttmtfmt. < tx_to
|
||||||
order by 1;
|
order by 1;
|
||||||
|
|||||||
@@ -12,17 +12,16 @@
|
|||||||
|
|
||||||
<h4> Service Outputs </h4>
|
<h4> Service Outputs </h4>
|
||||||
Output should be a single table called "work.dynamic_values" in the format
|
Output should be a single table called "work.dynamic_values" in the format
|
||||||
below. display_value should always be character, raw_value is unformatted
|
below. raw_value is unformatted haracter/numeric.
|
||||||
character/numeric.
|
|
||||||
|
|
||||||
<h5>DYNAMIC_VALUES</h5>
|
<h5>DYNAMIC_VALUES</h5>
|
||||||
The RAW_VALUE column may be charactor or numeric. If DISPLAY_INDEX is not
|
The RAW_VALUE column may be charactor or numeric. If DISPLAY_INDEX is not
|
||||||
provided, it is added automatically.
|
provided, it is added automatically.
|
||||||
|
|
||||||
|DISPLAY_INDEX:best.|DISPLAY_VALUE:$|RAW_VALUE|
|
|DISPLAY_INDEX:best.|RAW_VALUE|
|
||||||
|---|---|---|
|
|---|---|
|
||||||
|1|$77.43|77.43|
|
|1|77.43|
|
||||||
|2|$88.43|88.43|
|
|2|88.43|
|
||||||
|
|
||||||
<h5>DYNAMIC_EXTENDED_VALUES</h5>
|
<h5>DYNAMIC_EXTENDED_VALUES</h5>
|
||||||
This table is optional. If provided, it will map the DISPLAY_INDEX from the
|
This table is optional. If provided, it will map the DISPLAY_INDEX from the
|
||||||
@@ -35,17 +34,17 @@
|
|||||||
The FORCED_VALUE column can be used to force an extended value to be selected
|
The FORCED_VALUE column can be used to force an extended value to be selected
|
||||||
by default when a particular value is chosen.
|
by default when a particular value is chosen.
|
||||||
|
|
||||||
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_VALUE:$|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|FORCED_VALUE|
|
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|FORCED_VALUE|
|
||||||
|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
|1|DISCOUNT_RT|"50%"|N|0.5||.|
|
|1|DISCOUNT_RT|N|0.5||.|
|
||||||
|1|DISCOUNT_RT|"40%"|N|0.4||0|
|
|1|DISCOUNT_RT|N|0.4||0|
|
||||||
|1|DISCOUNT_RT|"30%"|N|0.3||1|
|
|1|DISCOUNT_RT|N|0.3||1|
|
||||||
|1|CURRENCY_SYMBOL|"GBP"|C||"GBP"|.|
|
|1|CURRENCY_SYMBOL|C||"GBP"|.|
|
||||||
|1|CURRENCY_SYMBOL|"RSD"|C||"RSD"|.|
|
|1|CURRENCY_SYMBOL|C||"RSD"|.|
|
||||||
|2|DISCOUNT_RT|"50%"|N|0.5||.|
|
|2|DISCOUNT_RT|N|0.5||.|
|
||||||
|2|DISCOUNT_RT|"40%"|N|0.4||1|
|
|2|DISCOUNT_RT|N|0.4||1|
|
||||||
|2|CURRENCY_SYMBOL|"EUR"|C||"EUR"|.|
|
|2|CURRENCY_SYMBOL|C||"EUR"|.|
|
||||||
|2|CURRENCY_SYMBOL|"HKD"|C||"HKD"|1|
|
|2|CURRENCY_SYMBOL|C||"HKD"|1|
|
||||||
|
|
||||||
<h4> SAS Macros </h4>
|
<h4> SAS Macros </h4>
|
||||||
@li dc_getlibs.sas
|
@li dc_getlibs.sas
|
||||||
@@ -67,38 +66,34 @@ create table work.source as
|
|||||||
where tx_to > &dc_dttmtfmt.
|
where tx_to > &dc_dttmtfmt.
|
||||||
order by 1,2;
|
order by 1,2;
|
||||||
|
|
||||||
data work.DYNAMIC_VALUES (keep=display_index raw_value display_value);
|
data work.DYNAMIC_VALUES (keep=display_index raw_value );
|
||||||
set work.source end=last;
|
set work.source end=last;
|
||||||
by libref;
|
by libref;
|
||||||
if last.libref then do;
|
if last.libref then do;
|
||||||
display_index+1;
|
display_index+1;
|
||||||
raw_value=libref;
|
raw_value=libref;
|
||||||
display_value=libref;
|
|
||||||
output;
|
output;
|
||||||
end;
|
end;
|
||||||
if last then do;
|
if last then do;
|
||||||
display_index+1;
|
display_index+1;
|
||||||
raw_value='*ALL*';
|
raw_value='*ALL*';
|
||||||
display_value='*ALL*';
|
|
||||||
output;
|
output;
|
||||||
end;
|
end;
|
||||||
run;
|
run;
|
||||||
|
|
||||||
|
|
||||||
data work.dynamic_extended_values(keep=display_index extra_col_name display_type
|
data work.dynamic_extended_values(keep=display_index extra_col_name display_type
|
||||||
display_value RAW_VALUE_CHAR raw_value_num forced_value);
|
RAW_VALUE_CHAR raw_value_num forced_value);
|
||||||
set work.source end=last;
|
set work.source end=last;
|
||||||
by libref dsn;
|
by libref dsn;
|
||||||
retain extra_col_name 'ALERT_DS';
|
retain extra_col_name 'ALERT_DS';
|
||||||
retain display_type 'C';
|
retain display_type 'C';
|
||||||
retain raw_value_num .;
|
retain raw_value_num .;
|
||||||
raw_value_char=dsn;
|
raw_value_char=dsn;
|
||||||
display_value=dsn;
|
|
||||||
forced_value=0;
|
forced_value=0;
|
||||||
|
|
||||||
if first.libref then display_index+1;
|
if first.libref then display_index+1;
|
||||||
if last.libref then do;
|
if last.libref then do;
|
||||||
display_value='*ALL*';
|
|
||||||
raw_value_char='*ALL*';
|
raw_value_char='*ALL*';
|
||||||
forced_value=1;
|
forced_value=1;
|
||||||
output;
|
output;
|
||||||
|
|||||||
@@ -13,17 +13,16 @@
|
|||||||
|
|
||||||
<h4> Service Outputs </h4>
|
<h4> Service Outputs </h4>
|
||||||
Output should be a single table called "work.dynamic_values" in the format
|
Output should be a single table called "work.dynamic_values" in the format
|
||||||
below. display_value should always be character, raw_value is unformatted
|
below.
|
||||||
character/numeric.
|
|
||||||
|
|
||||||
<h5>DYNAMIC_VALUES</h5>
|
<h5>DYNAMIC_VALUES</h5>
|
||||||
The RAW_VALUE column may be charactor or numeric. If DISPLAY_INDEX is not
|
The RAW_VALUE column may be charactor or numeric. If DISPLAY_INDEX is not
|
||||||
provided, it is added automatically.
|
provided, it is added automatically.
|
||||||
|
|
||||||
|DISPLAY_INDEX:best.|DISPLAY_VALUE:$|RAW_VALUE|
|
|DISPLAY_INDEX:best.|RAW_VALUE|
|
||||||
|---|---|---|
|
|---|---|
|
||||||
|1|$77.43|77.43|
|
|1|77.43|
|
||||||
|2|$88.43|88.43|
|
|2|88.43|
|
||||||
|
|
||||||
<h5>DYNAMIC_EXTENDED_VALUES</h5>
|
<h5>DYNAMIC_EXTENDED_VALUES</h5>
|
||||||
This table is optional. If provided, it will map the DISPLAY_INDEX from the
|
This table is optional. If provided, it will map the DISPLAY_INDEX from the
|
||||||
@@ -36,17 +35,18 @@
|
|||||||
The FORCED_VALUE column can be used to force an extended value to be selected
|
The FORCED_VALUE column can be used to force an extended value to be selected
|
||||||
by default when a particular value is chosen.
|
by default when a particular value is chosen.
|
||||||
|
|
||||||
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_VALUE:$|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|FORCED_VALUE|
|
|DISPLAY_INDEX|EXTRA_COL_NAME:$32.|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|
|
||||||
|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
|1|DISCOUNT_RT|"50%"|N|0.5||.|
|
|1|DISCOUNT_RT|N|0.5||
|
||||||
|1|DISCOUNT_RT|"40%"|N|0.4||0|
|
|1|DISCOUNT_RT|N|0.4||
|
||||||
|1|DISCOUNT_RT|"30%"|N|0.3||1|
|
|1|DISCOUNT_RT|N|0.3||
|
||||||
|1|CURRENCY_SYMBOL|"GBP"|C||"GBP"|.|
|
|1|CURRENCY_SYMBOL|C||"GBP"|
|
||||||
|1|CURRENCY_SYMBOL|"RSD"|C||"RSD"|.|
|
|1|CURRENCY_SYMBOL|C||"RSD"|
|
||||||
|2|DISCOUNT_RT|"50%"|N|0.5||.|
|
|2|DISCOUNT_RT|N|0.5||
|
||||||
|2|DISCOUNT_RT|"40%"|N|0.4||1|
|
|2|DISCOUNT_RT|N|0.4||
|
||||||
|2|CURRENCY_SYMBOL|"EUR"|C||"EUR"|.|
|
|2|CURRENCY_SYMBOL|C||"EUR"|
|
||||||
|2|CURRENCY_SYMBOL|"HKD"|C||"HKD"|1|
|
|2|CURRENCY_SYMBOL|C||"HKD"|
|
||||||
|
|
||||||
|
|
||||||
<h4> SAS Macros </h4>
|
<h4> SAS Macros </h4>
|
||||||
@li dc_getlibs.sas
|
@li dc_getlibs.sas
|
||||||
@@ -93,25 +93,24 @@ create table work.source as
|
|||||||
and memtype='DATA';
|
and memtype='DATA';
|
||||||
|
|
||||||
create table work.members as
|
create table work.members as
|
||||||
select distinct memname as display_value
|
select distinct upcase(memname) as raw_value
|
||||||
from work.source;
|
from work.source;
|
||||||
|
|
||||||
data work.DYNAMIC_VALUES;
|
data work.DYNAMIC_VALUES;
|
||||||
set work.members;
|
set work.members;
|
||||||
raw_value=display_value;
|
|
||||||
display_index=_n_;
|
display_index=_n_;
|
||||||
run;
|
run;
|
||||||
|
|
||||||
proc sql;
|
proc sql;
|
||||||
create table work.dynamic_extended_values as
|
create table work.dynamic_extended_values as
|
||||||
select a.display_index
|
select a.display_index
|
||||||
,b.name as display_value
|
,a.raw_value
|
||||||
,"C" as display_type
|
,"C" as display_type
|
||||||
,b.name as RAW_VALUE_CHAR
|
,b.name as RAW_VALUE_CHAR
|
||||||
,. as RAW_VALUE_NUM
|
,. as RAW_VALUE_NUM
|
||||||
from work.dynamic_values a
|
from work.dynamic_values a
|
||||||
left join work.source b
|
left join work.source b
|
||||||
on a.display_value=b.memname
|
on a.raw_value=b.memname
|
||||||
where b.type='num';
|
where b.type='num';
|
||||||
|
|
||||||
data work.dynamic_extended_values;
|
data work.dynamic_extended_values;
|
||||||
|
|||||||
@@ -12,12 +12,11 @@
|
|||||||
|
|
||||||
<h4> Service Outputs </h4>
|
<h4> Service Outputs </h4>
|
||||||
Output should be a single table called "work.dynamic_values" in the format
|
Output should be a single table called "work.dynamic_values" in the format
|
||||||
below. display_value should always be character, raw_value is unformatted
|
below.
|
||||||
character/numeric.
|
|
||||||
|
|
||||||
|DISPLAY_VALUE:$|RAW_VALUE:??|
|
|RAW_VALUE:??|
|
||||||
|---|---|
|
|---|
|
||||||
|$44.00|44|
|
|44|
|
||||||
|
|
||||||
<h4> SAS Macros </h4>
|
<h4> SAS Macros </h4>
|
||||||
@li dc_getlibs.sas
|
@li dc_getlibs.sas
|
||||||
@@ -27,8 +26,7 @@
|
|||||||
|
|
||||||
proc sql;
|
proc sql;
|
||||||
create table work.DYNAMIC_VALUES as
|
create table work.DYNAMIC_VALUES as
|
||||||
select distinct cats(some_num) as display_value,
|
select distinct some_num as raw_value
|
||||||
some_num as raw_value
|
|
||||||
from &libds
|
from &libds
|
||||||
order by 1;
|
order by 1;
|
||||||
|
|
||||||
|
|||||||
@@ -17,10 +17,10 @@
|
|||||||
The RAW_VALUE column may be charactor or numeric. If DISPLAY_INDEX is not
|
The RAW_VALUE column may be charactor or numeric. If DISPLAY_INDEX is not
|
||||||
provided, it is added automatically.
|
provided, it is added automatically.
|
||||||
|
|
||||||
|DISPLAY_INDEX:best.|DISPLAY_VALUE:$|RAW_VALUE|
|
|DISPLAY_INDEX:best.|RAW_VALUE|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|1|$77.43|77.43|
|
|1|77.43|
|
||||||
|2|$88.43|88.43|
|
|2|88.43|
|
||||||
|
|
||||||
<h5>DYNAMIC_EXTENDED_VALUES</h5>
|
<h5>DYNAMIC_EXTENDED_VALUES</h5>
|
||||||
This table is optional. If provided, it will map the DISPLAY_INDEX from the
|
This table is optional. If provided, it will map the DISPLAY_INDEX from the
|
||||||
@@ -30,17 +30,17 @@
|
|||||||
Should be used sparingly! The use of large tables here can slow down the
|
Should be used sparingly! The use of large tables here can slow down the
|
||||||
browser.
|
browser.
|
||||||
|
|
||||||
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_VALUE:$|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|
|
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|
|
||||||
|---|---|---|
|
|---|---|---|---|---|
|
||||||
|1|DISCOUNT_RT|"50%"|N|0.5||
|
|1|DISCOUNT_RT|N|0.5||
|
||||||
|1|DISCOUNT_RT|"40%"|N|0.4||
|
|1|DISCOUNT_RT|N|0.4||
|
||||||
|1|DISCOUNT_RT|"30%"|N|0.3||
|
|1|DISCOUNT_RT|N|0.3||
|
||||||
|1|CURRENCY_SYMBOL|"GBP"|C||"GBP"|
|
|1|CURRENCY_SYMBOL|C||"GBP"|
|
||||||
|1|CURRENCY_SYMBOL|"RSD"|C||"RSD"|
|
|1|CURRENCY_SYMBOL|C||"RSD"|
|
||||||
|2|DISCOUNT_RT|"50%"|N|0.5||
|
|2|DISCOUNT_RT|N|0.5||
|
||||||
|2|DISCOUNT_RT|"40%"|N|0.4||
|
|2|DISCOUNT_RT|N|0.4||
|
||||||
|2|CURRENCY_SYMBOL|"EUR"|C||"EUR"|
|
|2|CURRENCY_SYMBOL|C||"EUR"|
|
||||||
|2|CURRENCY_SYMBOL|"HKD"|C||"HKD"|
|
|2|CURRENCY_SYMBOL|C||"HKD"|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -55,7 +55,6 @@
|
|||||||
|
|
||||||
proc sql;
|
proc sql;
|
||||||
create table work.DYNAMIC_VALUES as
|
create table work.DYNAMIC_VALUES as
|
||||||
select distinct groupname as display_value,
|
select distinct groupname as raw_value
|
||||||
groupname as raw_value
|
|
||||||
from work.groups
|
from work.groups
|
||||||
order by 1;
|
order by 1;
|
||||||
|
|||||||
@@ -18,10 +18,10 @@
|
|||||||
The RAW_VALUE column may be charactor or numeric. If DISPLAY_INDEX is not
|
The RAW_VALUE column may be charactor or numeric. If DISPLAY_INDEX is not
|
||||||
provided, it is added automatically.
|
provided, it is added automatically.
|
||||||
|
|
||||||
|DISPLAY_INDEX:best.|DISPLAY_VALUE:$|RAW_VALUE|
|
|DISPLAY_INDEX:best.|RAW_VALUE|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|1|$77.43|77.43|
|
|1|77.43|
|
||||||
|2|$88.43|88.43|
|
|2|88.43|
|
||||||
|
|
||||||
<h5>DYNAMIC_EXTENDED_VALUES</h5>
|
<h5>DYNAMIC_EXTENDED_VALUES</h5>
|
||||||
This table is optional. If provided, it will map the DISPLAY_INDEX from the
|
This table is optional. If provided, it will map the DISPLAY_INDEX from the
|
||||||
@@ -31,17 +31,17 @@
|
|||||||
Should be used sparingly! The use of large tables here can slow down the
|
Should be used sparingly! The use of large tables here can slow down the
|
||||||
browser.
|
browser.
|
||||||
|
|
||||||
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_VALUE:$|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|
|
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|
|
||||||
|---|---|---|
|
|---|---|---|---|---|
|
||||||
|1|DISCOUNT_RT|"50%"|N|0.5||
|
|1|DISCOUNT_RT|N|0.5||
|
||||||
|1|DISCOUNT_RT|"40%"|N|0.4||
|
|1|DISCOUNT_RT|N|0.4||
|
||||||
|1|DISCOUNT_RT|"30%"|N|0.3||
|
|1|DISCOUNT_RT|N|0.3||
|
||||||
|1|CURRENCY_SYMBOL|"GBP"|C||"GBP"|
|
|1|CURRENCY_SYMBOL|C||"GBP"|
|
||||||
|1|CURRENCY_SYMBOL|"RSD"|C||"RSD"|
|
|1|CURRENCY_SYMBOL|C||"RSD"|
|
||||||
|2|DISCOUNT_RT|"50%"|N|0.5||
|
|2|DISCOUNT_RT|N|0.5||
|
||||||
|2|DISCOUNT_RT|"40%"|N|0.4||
|
|2|DISCOUNT_RT|N|0.4||
|
||||||
|2|CURRENCY_SYMBOL|"EUR"|C||"EUR"|
|
|2|CURRENCY_SYMBOL|C||"EUR"|
|
||||||
|2|CURRENCY_SYMBOL|"HKD"|C||"HKD"|
|
|2|CURRENCY_SYMBOL|C||"HKD"|
|
||||||
|
|
||||||
<h4> SAS Macros </h4>
|
<h4> SAS Macros </h4>
|
||||||
@li dc_assignlib.sas
|
@li dc_assignlib.sas
|
||||||
@@ -89,8 +89,7 @@ run;
|
|||||||
/* send back the raw and formatted values */
|
/* send back the raw and formatted values */
|
||||||
proc sql;
|
proc sql;
|
||||||
create table work.DYNAMIC_VALUES as
|
create table work.DYNAMIC_VALUES as
|
||||||
select distinct name as display_value,
|
select distinct upcase(name) as raw_value
|
||||||
upcase(name) as raw_value
|
|
||||||
from work.members
|
from work.members
|
||||||
where MemType='DATA'
|
where MemType='DATA'
|
||||||
order by 1;
|
order by 1;
|
||||||
|
|||||||
@@ -22,10 +22,10 @@
|
|||||||
The RAW_VALUE column may be charactor or numeric. If DISPLAY_INDEX is not
|
The RAW_VALUE column may be charactor or numeric. If DISPLAY_INDEX is not
|
||||||
provided, it is added automatically.
|
provided, it is added automatically.
|
||||||
|
|
||||||
|DISPLAY_INDEX:best.|DISPLAY_VALUE:$|RAW_VALUE|
|
|DISPLAY_INDEX:best.|RAW_VALUE|
|
||||||
|---|---|---|
|
|---|---|
|
||||||
|1|$77.43|77.43|
|
|1|77.43|
|
||||||
|2|$88.43|88.43|
|
|2|88.43|
|
||||||
|
|
||||||
<h5>DYNAMIC_EXTENDED_VALUES</h5>
|
<h5>DYNAMIC_EXTENDED_VALUES</h5>
|
||||||
This table is optional. If provided, it will map the DISPLAY_INDEX from the
|
This table is optional. If provided, it will map the DISPLAY_INDEX from the
|
||||||
@@ -35,17 +35,17 @@
|
|||||||
Should be used sparingly! The use of large tables here can slow down the
|
Should be used sparingly! The use of large tables here can slow down the
|
||||||
browser.
|
browser.
|
||||||
|
|
||||||
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_VALUE:$|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|
|
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
|1|DISCOUNT_RT|"50%"|N|0.5|` `|
|
|1|DISCOUNT_RT|N|0.5||
|
||||||
|1|DISCOUNT_RT|"40%"|N|0.4|` `|
|
|1|DISCOUNT_RT|N|0.4||
|
||||||
|1|DISCOUNT_RT|"30%"|N|0.3|` `|
|
|1|DISCOUNT_RT|N|0.3||
|
||||||
|1|CURRENCY_SYMBOL|"GBP"|C|` `|"GBP"|
|
|1|CURRENCY_SYMBOL|C||"GBP"|
|
||||||
|1|CURRENCY_SYMBOL|"RSD"|C|` `|"RSD"|
|
|1|CURRENCY_SYMBOL|C||"RSD"|
|
||||||
|2|DISCOUNT_RT|"50%"|N|0.5|` `|
|
|2|DISCOUNT_RT|N|0.5||
|
||||||
|2|DISCOUNT_RT|"40%"|N|0.4|` `|
|
|2|DISCOUNT_RT|N|0.4||
|
||||||
|2|CURRENCY_SYMBOL|"EUR"|C|` `|"EUR"|
|
|2|CURRENCY_SYMBOL|C||"EUR"|
|
||||||
|2|CURRENCY_SYMBOL|"HKD"|C|` `|"HKD"|
|
|2|CURRENCY_SYMBOL|C||"HKD"|
|
||||||
|
|
||||||
**/
|
**/
|
||||||
|
|
||||||
@@ -61,8 +61,7 @@ run;
|
|||||||
|
|
||||||
proc sql;
|
proc sql;
|
||||||
create table work.DYNAMIC_VALUES as
|
create table work.DYNAMIC_VALUES as
|
||||||
select distinct dsn as display_value,
|
select distinct upcase(dsn) as raw_value
|
||||||
upcase(dsn) as raw_value
|
|
||||||
from &mpelib..mpe_tables
|
from &mpelib..mpe_tables
|
||||||
(where=(&dc_dttmtfmt. < tx_to))
|
(where=(&dc_dttmtfmt. < tx_to))
|
||||||
where libref in (select &srccol from work.source_row)
|
where libref in (select &srccol from work.source_row)
|
||||||
|
|||||||
@@ -17,10 +17,10 @@
|
|||||||
The RAW_VALUE column may be charactor or numeric. If DISPLAY_INDEX is not
|
The RAW_VALUE column may be charactor or numeric. If DISPLAY_INDEX is not
|
||||||
provided, it is added automatically.
|
provided, it is added automatically.
|
||||||
|
|
||||||
|DISPLAY_INDEX:best.|DISPLAY_VALUE:$|RAW_VALUE|
|
|DISPLAY_INDEX:best.|RAW_VALUE|
|
||||||
|---|---|---|
|
|---|---|
|
||||||
|1|$77.43|77.43|
|
|1|77.43|
|
||||||
|2|$88.43|88.43|
|
|2|88.43|
|
||||||
|
|
||||||
<h5>DYNAMIC_EXTENDED_VALUES</h5>
|
<h5>DYNAMIC_EXTENDED_VALUES</h5>
|
||||||
This table is optional. If provided, it will map the DISPLAY_INDEX from the
|
This table is optional. If provided, it will map the DISPLAY_INDEX from the
|
||||||
@@ -30,17 +30,17 @@
|
|||||||
Should be used sparingly! The use of large tables here can slow down the
|
Should be used sparingly! The use of large tables here can slow down the
|
||||||
browser.
|
browser.
|
||||||
|
|
||||||
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_VALUE:$|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|
|
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|
|
||||||
|---|---|---|
|
|---|---|---|---|---|
|
||||||
|1|DISCOUNT_RT|"50%"|N|0.5||
|
|1|DISCOUNT_RT|N|0.5||
|
||||||
|1|DISCOUNT_RT|"40%"|N|0.4||
|
|1|DISCOUNT_RT|N|0.4||
|
||||||
|1|DISCOUNT_RT|"30%"|N|0.3||
|
|1|DISCOUNT_RT|N|0.3||
|
||||||
|1|CURRENCY_SYMBOL|"GBP"|C||"GBP"|
|
|1|CURRENCY_SYMBOL|C||"GBP"|
|
||||||
|1|CURRENCY_SYMBOL|"RSD"|C||"RSD"|
|
|1|CURRENCY_SYMBOL|C||"RSD"|
|
||||||
|2|DISCOUNT_RT|"50%"|N|0.5||
|
|2|DISCOUNT_RT|N|0.5||
|
||||||
|2|DISCOUNT_RT|"40%"|N|0.4||
|
|2|DISCOUNT_RT|N|0.4||
|
||||||
|2|CURRENCY_SYMBOL|"EUR"|C||"EUR"|
|
|2|CURRENCY_SYMBOL|C||"EUR"|
|
||||||
|2|CURRENCY_SYMBOL|"HKD"|C||"HKD"|
|
|2|CURRENCY_SYMBOL|C||"HKD"|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -55,7 +55,6 @@
|
|||||||
|
|
||||||
proc sql;
|
proc sql;
|
||||||
create table work.DYNAMIC_VALUES as
|
create table work.DYNAMIC_VALUES as
|
||||||
select distinct groupname as display_value,
|
select distinct groupuri as raw_value
|
||||||
groupuri as raw_value
|
|
||||||
from work.groups
|
from work.groups
|
||||||
order by 1;
|
order by 1;
|
||||||
|
|||||||
Reference in New Issue
Block a user