Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
77b4bb310e | ||
|
|
47ba03ec63 | ||
|
|
e331e1bb21 | ||
|
|
40f285f295 | ||
|
|
2d29346cbf | ||
|
|
f70ea2fe71 | ||
|
|
29aaa72c60 | ||
|
|
12c7d30894 | ||
|
|
38cb1e207b | ||
|
|
4c879c614b | ||
|
|
8e012f760a | ||
|
|
8c09befd74 | ||
|
|
6af49cf1fd | ||
|
|
4ea604f9fb | ||
|
|
9d97bf7ea1 | ||
|
|
eb015d712b | ||
|
|
1d04f4a42c | ||
|
|
11ee49a57a | ||
|
|
609731ff99 | ||
|
|
d6cb32ed25 | ||
|
|
3668a7426f | ||
|
|
cc82dcaafe | ||
|
|
ea03bdecc5 | ||
|
|
51071b463b | ||
|
|
ac0bd10212 | ||
|
|
1b73e355b7 | ||
|
|
b661580c60 | ||
|
|
dc4e07a692 | ||
|
|
f2313b31f1 | ||
|
|
f8810ee7e9 | ||
|
|
8ab4af8397 | ||
|
|
2382a559a5 | ||
|
|
5d889d824c | ||
|
|
bed21122ce | ||
|
|
ea8cf71101 | ||
|
|
f1a26e132e | ||
|
|
1db6984de3 | ||
|
|
636ff237dd | ||
|
|
02963ab6d5 | ||
|
|
d40f61292a | ||
|
|
7d94cb2ae4 | ||
|
|
bb80476767 | ||
|
|
1635bc9c45 | ||
|
|
f031b4eb89 | ||
|
|
93d4ab65ac | ||
|
|
ce921a032a | ||
|
|
322f904b4b | ||
|
|
982eeac58c | ||
|
|
0ab9717556 | ||
|
|
24a85de8e1 | ||
|
|
65f0b979a4 | ||
|
|
947f34a0ad | ||
|
|
0f60fd7181 | ||
|
|
251062e42e | ||
|
|
05a328976e | ||
|
|
503cb08b2f | ||
|
|
f71be20476 | ||
|
|
e6397cecc1 | ||
|
|
80ce80ece4 | ||
|
|
9546fcd631 | ||
|
|
b79aaf4327 | ||
|
|
76f9198f73 | ||
|
|
d60029deae | ||
|
|
d26f7d2511 | ||
|
|
33efe09b50 | ||
|
|
e0aef9bf00 | ||
|
|
02d1a2e0b1 | ||
|
|
4e3154e929 | ||
|
|
32c0713256 | ||
|
|
defe15bcec | ||
|
|
6f8e471f16 | ||
|
|
dc35abfd85 | ||
|
|
04a8c5d52a | ||
|
|
2cb370053d | ||
|
|
1707f3802a | ||
|
|
c87ba660ca | ||
|
|
ef8a2dbc38 | ||
|
|
40d04a53c4 | ||
|
|
d5ebb01ce3 | ||
|
|
ec66631a33 | ||
|
|
d66eb5dfc2 | ||
|
|
731b589ed8 | ||
|
|
fe92d5fc36 | ||
|
|
a335b400f1 | ||
|
|
f63e507ddf | ||
|
|
991cc0567d | ||
|
|
52d58036a4 | ||
|
|
26bff85792 | ||
|
|
2ccf0d1100 |
@@ -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.
|
||||
```
|
||||
@@ -3,7 +3,7 @@ run-name: Running Lint Check and Licence checker on Pull Request
|
||||
on: [pull_request]
|
||||
|
||||
env:
|
||||
NODE_VERSION: '24.5.0'
|
||||
NODE_VERSION: '24.15.0'
|
||||
|
||||
jobs:
|
||||
Build-and-ng-test:
|
||||
@@ -31,7 +31,10 @@ jobs:
|
||||
run: |
|
||||
cd client
|
||||
# Decrypt and Install sheet
|
||||
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
||||
echo "${{ secrets.SHEET_PWD }}" | \
|
||||
gpg --batch --yes --passphrase-fd 0 \
|
||||
--output ./libraries/sheet-crypto.tgz \
|
||||
--decrypt ./libraries/sheet-crypto.tgz.gpg
|
||||
npm ci
|
||||
|
||||
- name: Check audit
|
||||
@@ -66,6 +69,9 @@ jobs:
|
||||
needs: Build-and-ng-test
|
||||
env:
|
||||
CHROME_BIN: /usr/bin/google-chrome
|
||||
# Pin OS locale
|
||||
LANG: en_GB.UTF-8
|
||||
LC_ALL: en_GB.UTF-8
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -83,7 +89,10 @@ jobs:
|
||||
apt-get update
|
||||
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
||||
apt install -y ./google-chrome*.deb
|
||||
apt-get -y install libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2t64 libxtst6 xauth xvfb jq zip
|
||||
apt-get -y install libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2t64 libxtst6 xauth xvfb jq zip locales
|
||||
# Generate the en_GB.UTF-8 locale referenced by LANG/LC_ALL
|
||||
locale-gen en_GB.UTF-8
|
||||
update-locale LANG=en_GB.UTF-8 LC_ALL=en_GB.UTF-8
|
||||
|
||||
- name: Write cypress credentials
|
||||
run: echo "$CYPRESS_CREDS" > ./client/cypress.env.json
|
||||
@@ -120,6 +129,7 @@ jobs:
|
||||
# sasjs request services/admin/makedata -t server-ci -d ./deploy/makeData4GL.json -c ./deploy/requestConfig.json -o ./output.json
|
||||
|
||||
- name: Prepare and run frontend and cypress
|
||||
timeout-minutes: 35
|
||||
run: |
|
||||
cd ./client
|
||||
mv ./cypress.env.example.json ./cypress.env.json
|
||||
@@ -134,11 +144,14 @@ jobs:
|
||||
replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts
|
||||
cat ./cypress.config.ts
|
||||
# Start frontend and run cypress
|
||||
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts"
|
||||
# timeout 1800: SIGTERM after 30 min so Cypress can flush video/screenshots
|
||||
# before the outer timeout-minutes hard-kills the step (avoids silent multi-hour hangs)
|
||||
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && timeout 1800 npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts"
|
||||
|
||||
- name: Zip Cypress videos
|
||||
if: always()
|
||||
run: |
|
||||
mkdir -p ./client/cypress/videos
|
||||
zip -r cypress-videos ./client/cypress/videos
|
||||
|
||||
- name: Add cypress videos artifacts
|
||||
|
||||
@@ -3,7 +3,7 @@ run-name: Running Lighthouse Performance and Accessibility Checks on Pull Reques
|
||||
on: [pull_request]
|
||||
|
||||
env:
|
||||
NODE_VERSION: '24.5.0'
|
||||
NODE_VERSION: '24.15.0'
|
||||
|
||||
jobs:
|
||||
lighthouse:
|
||||
@@ -48,7 +48,10 @@ jobs:
|
||||
run: |
|
||||
cd client
|
||||
# Decrypt and Install sheet
|
||||
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
||||
echo "${{ secrets.SHEET_PWD }}" | \
|
||||
gpg --batch --yes --passphrase-fd 0 \
|
||||
--output ./libraries/sheet-crypto.tgz \
|
||||
--decrypt ./libraries/sheet-crypto.tgz.gpg
|
||||
npm ci
|
||||
npm install -g replace-in-files-cli
|
||||
|
||||
|
||||
@@ -47,9 +47,9 @@ jobs:
|
||||
- name: Check audit
|
||||
# Audit should fail and stop the CI if critical vulnerability found
|
||||
run: |
|
||||
npm audit --omit=dev
|
||||
npm audit --audit-level=critical --omit=dev
|
||||
cd ./sas
|
||||
npm audit --omit=dev
|
||||
npm audit --audit-level=critical --omit=dev
|
||||
cd ../client
|
||||
npm audit --audit-level=critical --omit=dev
|
||||
|
||||
@@ -69,6 +69,9 @@ jobs:
|
||||
needs: Build-production-and-ng-test
|
||||
env:
|
||||
CHROME_BIN: /usr/bin/google-chrome
|
||||
# Pin OS locale
|
||||
LANG: en_GB.UTF-8
|
||||
LC_ALL: en_GB.UTF-8
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -86,7 +89,10 @@ jobs:
|
||||
apt-get update
|
||||
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
||||
apt install -y ./google-chrome*.deb
|
||||
apt-get -y install libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2t64 libxtst6 xauth xvfb jq zip
|
||||
apt-get -y install libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2t64 libxtst6 xauth xvfb jq zip locales
|
||||
# Generate the en_GB.UTF-8 locale referenced by LANG/LC_ALL
|
||||
locale-gen en_GB.UTF-8
|
||||
update-locale LANG=en_GB.UTF-8 LC_ALL=en_GB.UTF-8
|
||||
|
||||
- name: Write cypress credentials
|
||||
run: echo "$CYPRESS_CREDS" > ./client/cypress.env.json
|
||||
@@ -142,6 +148,7 @@ jobs:
|
||||
- name: Zip Cypress videos
|
||||
if: always()
|
||||
run: |
|
||||
mkdir -p ./client/cypress/videos
|
||||
zip -r cypress-videos ./client/cypress/videos
|
||||
|
||||
- name: Add cypress videos artifacts
|
||||
@@ -154,6 +161,16 @@ jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [Build-production-and-ng-test, Build-and-test-development]
|
||||
# The semantic-release step below uses the per-job token injected by Gitea
|
||||
# Actions (secrets.GITEA_TOKEN). That token is scoped to THIS repository
|
||||
# and its permissions are derived from this block.
|
||||
# contents: write -> push CHANGELOG.md / package.json commit + tag, and
|
||||
# create the Release via the Gitea API.
|
||||
# The TSDOC_TOKEN and CODE_DATACONTROLLER_IO secrets continue to be used
|
||||
# because they target *different* servers (webdoc.datacontroller.io and
|
||||
# code.datacontroller.io), not the Gitea API.
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -186,12 +203,41 @@ jobs:
|
||||
npm run build
|
||||
|
||||
- name: Create Empty Release (assets are posted later)
|
||||
env:
|
||||
# Use the ephemeral per-job token injected by Gitea Actions instead
|
||||
# of a manually-managed PAT. This token always reports push=true on
|
||||
# its own repo via the Gitea API, which is what the
|
||||
# @saithodev/semantic-release-gitea verifyConditions step checks.
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
GITEA_URL: ${{ gitea.server_url }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm i
|
||||
npm i -g semantic-release
|
||||
# We do a semantic-release DRY RUN to make the job fail if there are no changes to release
|
||||
GITEA_TOKEN=${{ secrets.RELEASE_TOKEN }} GITEA_URL=https://git.datacontroller.io semantic-release --dry-run | grep -q "There are no relevant changes, so no new version is released." && exit 1
|
||||
GITEA_TOKEN=${{ secrets.RELEASE_TOKEN }} GITEA_URL=https://git.datacontroller.io semantic-release
|
||||
|
||||
# We do a semantic-release DRY RUN to make the job fail if there are
|
||||
# no changes to release. The previous implementation piped --dry-run
|
||||
# into `grep`, which swallowed semantic-release's non-zero exit codes
|
||||
# (e.g. auth/verifyConditions failures) — the real error then only
|
||||
# surfaced from the second invocation. Capture output and inspect it
|
||||
# so authentication failures fail the step cleanly with context.
|
||||
echo "::group::semantic-release --dry-run"
|
||||
DRY_EXIT=0
|
||||
DRY_OUTPUT=$(semantic-release --dry-run 2>&1) || DRY_EXIT=$?
|
||||
echo "$DRY_OUTPUT"
|
||||
echo "::endgroup::"
|
||||
|
||||
if [ "$DRY_EXIT" -ne 0 ]; then
|
||||
echo "::error::semantic-release dry-run failed (exit $DRY_EXIT). See log above; common cause is the Gitea token lacking push permission on this repo (verifyConditions of @saithodev/semantic-release-gitea)."
|
||||
exit "$DRY_EXIT"
|
||||
fi
|
||||
|
||||
if echo "$DRY_OUTPUT" | grep -q "There are no relevant changes, so no new version is released."; then
|
||||
echo "::error::No releasable changes since last tag - aborting release job."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
semantic-release
|
||||
|
||||
- name: Frontend Build
|
||||
description: Must be created AFTER the release as the version (git tag) is used in the interface
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
legacy-peer-deps=true
|
||||
ignore-scripts=true
|
||||
save-exact=true
|
||||
fund=false
|
||||
+132
@@ -1,3 +1,135 @@
|
||||
# [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)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* bumping ws package ([2382a55](https://git.datacontroller.io/dc/dc/commit/2382a559a5ac32b0f815776a90207650d5809ba6))
|
||||
* enabling version restore for non admin users ([5d889d8](https://git.datacontroller.io/dc/dc/commit/5d889d824cc2f8e4ea089cbb578453125dc4ba6c))
|
||||
|
||||
## [7.8.1](https://git.datacontroller.io/dc/dc/compare/v7.8.0...v7.8.1) (2026-05-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **sasjs:** enable runAsTask ([f1a26e1](https://git.datacontroller.io/dc/dc/commit/f1a26e132eba7fa2ac64754940b52ea46c6619b3))
|
||||
|
||||
# [7.8.0](https://git.datacontroller.io/dc/dc/compare/v7.7.3...v7.8.0) (2026-05-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* enabling DSN=*ALL* in MPE_SECURITY ([7d94cb2](https://git.datacontroller.io/dc/dc/commit/7d94cb2ae4a3f6c1fa1011ae0fced7083a2f2793))
|
||||
* providing default values for RULE_ACTIVE on MPE_VALIDATIONS ([f031b4e](https://git.datacontroller.io/dc/dc/commit/f031b4eb8925397e60dcc739a721cfbbb6da8dff))
|
||||
* switch away from api usage for CASLIB metadata ([ce921a0](https://git.datacontroller.io/dc/dc/commit/ce921a032a8970b8078a463a41da884e1fa71bc3))
|
||||
* use correct debug param for runAsTask ([bb80476](https://git.datacontroller.io/dc/dc/commit/bb8047676749814d3b86eea666726dbe4bf5f270))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add runAsTask config attribute parser ([1635bc9](https://git.datacontroller.io/dc/dc/commit/1635bc9c451bc221f386241007f594096f114b4f))
|
||||
* enabling *ALL* option by default in MPE_SECURITY (DSN col) ([93d4ab6](https://git.datacontroller.io/dc/dc/commit/93d4ab65acce7b5b35e448146f9893964ad2cca3))
|
||||
|
||||
## [7.7.3](https://git.datacontroller.io/dc/dc/compare/v7.7.2...v7.7.3) (2026-05-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* move cas session assign to settings.sas and abort when lib is unassigned ([65f0b97](https://git.datacontroller.io/dc/dc/commit/65f0b979a401277b3e070d409659ae3fae2ff8c0))
|
||||
|
||||
## [7.7.2](https://git.datacontroller.io/dc/dc/compare/v7.7.1...v7.7.2) (2026-05-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **client:** bundle Metropolis font locally to satisfy CSP ([9546fcd](https://git.datacontroller.io/dc/dc/commit/9546fcd6312f3e81f746ef6e32ef398810ed434a))
|
||||
* **client:** clear angular build cache on font strip to avoid stale dist ([503cb08](https://git.datacontroller.io/dc/dc/commit/503cb08b2fa40397434189f9c20eff3358eb7010))
|
||||
* **client:** postinstall removal of Metropolis [@font-face](https://git.datacontroller.io/font-face) from @clr/ui ([e6397ce](https://git.datacontroller.io/dc/dc/commit/e6397cecc13afe2a9238bdfb2b4b9b81f38d055c))
|
||||
* **client:** serve text-security-disc font locally ([80ce80e](https://git.datacontroller.io/dc/dc/commit/80ce80ece40012e59c7cd0340b4aa9a9aca46443))
|
||||
* **editor:** preserve numeric type for SAS num cols with static SOFTSELECT/HARDSELECT ([05a3289](https://git.datacontroller.io/dc/dc/commit/05a328976ea3d1d6ef7559850369aa580f0d067f))
|
||||
|
||||
## [7.7.1](https://git.datacontroller.io/dc/dc/compare/v7.7.0...v7.7.1) (2026-05-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **client:** bump adapter ([d26f7d2](https://git.datacontroller.io/dc/dc/commit/d26f7d2511008634124c7d6fde115abb43db9c43))
|
||||
* **sas:** bump cli ([d60029d](https://git.datacontroller.io/dc/dc/commit/d60029deae0ec21f3b8570461e2a4ca041d58f72))
|
||||
|
||||
# [7.7.0](https://git.datacontroller.io/dc/dc/compare/v7.6.0...v7.7.0) (2026-05-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* bump adapter to 4.16.6 ([1707f38](https://git.datacontroller.io/dc/dc/commit/1707f3802a97de8c659f1a88c92fc917e8a30615))
|
||||
* remove data:image/svg+xml CSP violation, use class instead changing style directly ([d66eb5d](https://git.datacontroller.io/dc/dc/commit/d66eb5dfc2dbb01f1e6c0c7d15fc2ad2a39dd829))
|
||||
* remove WORK, SASUSER and CASUSER as library options. [#224](https://git.datacontroller.io/dc/dc/issues/224) ([ec66631](https://git.datacontroller.io/dc/dc/commit/ec66631a33aabb8ab2f92fe22c15440127085782))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* auto-save CAS tables [#224](https://git.datacontroller.io/dc/dc/issues/224) ([40d04a5](https://git.datacontroller.io/dc/dc/commit/40d04a53c4c00183116bdbd08397e0f2ffb1f578))
|
||||
* autoload CAS tables. [#224](https://git.datacontroller.io/dc/dc/issues/224) ([d5ebb01](https://git.datacontroller.io/dc/dc/commit/d5ebb01ce381f5f4ec06de041f3ab9e632c02e43))
|
||||
|
||||
# [7.6.0](https://git.datacontroller.io/dc/dc/compare/v7.5.0...v7.6.0) (2026-04-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add label and tooltip for libref download, sanitise input ([52d5803](https://git.datacontroller.io/dc/dc/commit/52d58036a40e25847e900f9b04a77dbcc409c12b))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* configurable email alerts. Closes [#217](https://git.datacontroller.io/dc/dc/issues/217) ([2ccf0d1](https://git.datacontroller.io/dc/dc/commit/2ccf0d11000129629a0665421135b7530af9892f))
|
||||
|
||||
# [7.5.0](https://git.datacontroller.io/dc/dc/compare/v7.4.1...v7.5.0) (2026-04-03)
|
||||
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ This project includes automated Lighthouse performance and accessibility checks
|
||||
The Lighthouse CI workflow:
|
||||
1. Sets up the development environment with SASjs server and mocked services
|
||||
2. Builds and serves the Angular frontend
|
||||
3. Runs Lighthouse CI against key application pages
|
||||
3. Installs Chrome and runs `lhci autorun` (Lighthouse CI) against key pages
|
||||
4. Uploads results as artifacts for review
|
||||
|
||||
To run Lighthouse checks locally:
|
||||
@@ -61,4 +61,4 @@ npm install
|
||||
npm run lighthouse
|
||||
```
|
||||
|
||||
Configuration is in `client/lighthouserc.js`.
|
||||
Configuration is in `client/lighthouserc.js` (URL list, `desktop` preset, Chrome flags, assertions).
|
||||
|
||||
@@ -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
|
||||
+11
-2
@@ -25,6 +25,7 @@
|
||||
"options": {
|
||||
"allowedCommonJsDependencies": [
|
||||
"handsontable",
|
||||
"exceljs",
|
||||
"core-js",
|
||||
"pikaday",
|
||||
"querystring",
|
||||
@@ -62,11 +63,19 @@
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "src/images",
|
||||
"output": "images"
|
||||
"output": "images",
|
||||
"ignore": ["spinner.svg", "caret.svg"]
|
||||
}
|
||||
],
|
||||
"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",
|
||||
"main": "src/main.ts"
|
||||
},
|
||||
|
||||
+26
-18
@@ -1,13 +1,13 @@
|
||||
import { defineConfig } from "cypress";
|
||||
import { defineConfig } from 'cypress'
|
||||
|
||||
export default defineConfig({
|
||||
reporter: "mochawesome",
|
||||
reporter: 'mochawesome',
|
||||
|
||||
reporterOptions: {
|
||||
reportDir: "cypress/results",
|
||||
reportDir: 'cypress/results',
|
||||
overwrite: false,
|
||||
html: true,
|
||||
json: false,
|
||||
json: false
|
||||
},
|
||||
viewportHeight: 900,
|
||||
viewportWidth: 1600,
|
||||
@@ -16,24 +16,32 @@ export default defineConfig({
|
||||
defaultCommandTimeout: 30000,
|
||||
|
||||
env: {
|
||||
hosturl: "http://localhost:4200",
|
||||
appLocation: "",
|
||||
site_id_SAS9: "70221618",
|
||||
site_id_SASVIYA: "70253615",
|
||||
site_id_SASJS: "123",
|
||||
serverType: "SASJS",
|
||||
libraryToOpenIncludes_SASVIYA: "viya",
|
||||
libraryToOpenIncludes_SAS9: "dc",
|
||||
libraryToOpenIncludes_SASJS: "dc",
|
||||
hosturl: 'http://localhost:4200',
|
||||
appLocation: '',
|
||||
site_id_SAS9: '70221618',
|
||||
site_id_SASVIYA: '70253615',
|
||||
site_id_SASJS: '123',
|
||||
serverType: 'SASJS',
|
||||
libraryToOpenIncludes_SASVIYA: 'viya',
|
||||
libraryToOpenIncludes_SAS9: 'dc',
|
||||
libraryToOpenIncludes_SASJS: 'dc',
|
||||
debug: false,
|
||||
screenshotOnRunFailure: false,
|
||||
longerCommandTimeout: 50000,
|
||||
testLicenceUserLimits: false,
|
||||
testLicenceUserLimits: false
|
||||
},
|
||||
|
||||
e2e: {
|
||||
video: true,
|
||||
setupNodeEvents(on, config) {
|
||||
// implement node event listeners here
|
||||
},
|
||||
},
|
||||
});
|
||||
// Pin the browser locale so locale-formatted cells (intl-date/time/datetime)
|
||||
// render deterministically regardless of the runner's system locale.
|
||||
on('before:browser:launch', (browser, launchOptions) => {
|
||||
if (browser.family === 'chromium' && browser.name !== 'electron') {
|
||||
launchOptions.args.push('--lang=en-GB')
|
||||
}
|
||||
return launchOptions
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -539,7 +539,9 @@ const checkResultOfFormulaUpload = (callback?: any) => {
|
||||
}
|
||||
|
||||
const checkResultOfXLSUpload = (callback?: any) => {
|
||||
cy.viewport(1280, 720)
|
||||
// Config-default width — wide enough that the date/datetime/time columns are
|
||||
// not virtualized away (the old 1280 width hid the trailing time columns).
|
||||
cy.viewport(1600, 900)
|
||||
cy.get('#hotTable', { timeout: 30000 })
|
||||
.find('div.ht_master.handsontable')
|
||||
.find('div.wtHolder')
|
||||
@@ -557,14 +559,15 @@ const checkResultOfXLSUpload = (callback?: any) => {
|
||||
expect(cell.innerText).to.equal('▼\nOption 1')
|
||||
cell = data[0].children[0].children[5]
|
||||
expect(cell.innerText).to.equal('42')
|
||||
// Cells render the raw stored ISO string verbatim (locale-independent;
|
||||
// see registerIntlCellTypes). date -> YYYY-MM-DD, datetime ->
|
||||
// YYYY-MM-DD HH:mm:ss, time -> HH:mm:ss.
|
||||
cell = data[0].children[0].children[6]
|
||||
expect(cell.innerText).to.equal('▼\n1960-02-12')
|
||||
// When CI detached browser screen is smaller, below cells are not visible so test fails
|
||||
// Commenting it out now until we figure out workaround
|
||||
// cell = data[0].children[0].children[7]
|
||||
// expect(cell.innerText).to.equal('▼\n1960-01-01 00:00:42')
|
||||
// cell = data[0].children[0].children[8]
|
||||
// expect(cell.innerText).to.equal('00:00:42')
|
||||
expect(cell.innerText).to.equal('1960-02-12')
|
||||
cell = data[0].children[0].children[7]
|
||||
expect(cell.innerText).to.equal('1960-01-01 00:00:42')
|
||||
cell = data[0].children[0].children[8]
|
||||
expect(cell.innerText).to.equal('00:00:42')
|
||||
|
||||
if (callback) callback()
|
||||
})
|
||||
|
||||
@@ -20,4 +20,16 @@ import './commands'
|
||||
// require('./commands')
|
||||
|
||||
import 'cypress-plugin-tab'
|
||||
import "cypress-real-events"
|
||||
import 'cypress-real-events'
|
||||
|
||||
// Pin the locale
|
||||
Cypress.on('window:before:load', (win) => {
|
||||
Object.defineProperty(win.navigator, 'language', {
|
||||
value: 'en-GB',
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(win.navigator, 'languages', {
|
||||
value: ['en-GB'],
|
||||
configurable: true
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ const check = (cwd) => {
|
||||
onlyAllow:
|
||||
'AFLv2.1;Apache 2.0;Apache-2.0;Apache*;Artistic-2.0;0BSD;BSD*;BSD-2-Clause;BSD-3-Clause;CC0-1.0;CC-BY-3.0;CC-BY-4.0;ISC;MIT;MPL-2.0;ODC-By-1.0;Python-2.0;Unlicense;',
|
||||
excludePackages:
|
||||
'@cds/city@1.1.0;@handsontable/angular-wrapper@16.0.1;handsontable@^16.0.1;handsontable@16.2.0;hyperformula@2.7.1;hyperformula@3.0.0;hyperformula@3.1.0;jackspeak@3.4.3;path-scurry@1.11.1;package-json-from-dist@1.0.1'
|
||||
'@cds/city@1.1.0;@handsontable/angular-wrapper@16.0.1;@handsontable/angular-wrapper@17.1.0;handsontable@^16.0.1;handsontable@16.2.0;handsontable@17.1.0;hyperformula@2.7.1;hyperformula@3.0.0;hyperformula@3.1.0;hyperformula@3.2.0;hyperformula@3.3.0;jackspeak@3.4.3;path-scurry@1.11.1;package-json-from-dist@1.0.1;buffers@0.1.1'
|
||||
},
|
||||
(error, json) => {
|
||||
if (error) {
|
||||
|
||||
Generated
+6672
-6709
File diff suppressed because it is too large
Load Diff
+33
-22
@@ -17,13 +17,17 @@
|
||||
"deploy_viya": "rsync -avhe ssh ./dist/* --delete ${npm_config_account}@sas.4gl.io:/var/www/html/${npm_config_account}/dc/dev",
|
||||
"deploy_sasjs": "rsync -avhe ssh ./dist/* --delete root@${npm_config_account}.4gl.io:/var/www/html/dc/dev",
|
||||
"viyabuild": "cd build; ./viyabuild.sh",
|
||||
"lint": "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:headless": "npx ng test --no-watch --no-progress --browsers ChromeHeadlessCI",
|
||||
"watch": "ng test watch=true",
|
||||
"pree2e": "webdriver-manager update",
|
||||
"e2e": "protractor protractor.config.js",
|
||||
"postinstall": "node ./src/version.ts && npm run add-githook",
|
||||
"postinstall": "node ./src/version.ts && npm run add-githook && node ./scripts/strip-clr-base64-fonts.mjs && node ./scripts/gen-hot-icons.mjs",
|
||||
"add-githook": "[ -d ../.git ] && git config core.hooksPath ./.git-hooks || true",
|
||||
"cypress": "cypress open",
|
||||
"cy:run": "cypress run",
|
||||
@@ -37,21 +41,21 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^19.2.18",
|
||||
"@angular/animations": "^19.2.20",
|
||||
"@angular/cdk": "^19.2.19",
|
||||
"@angular/common": "^19.2.18",
|
||||
"@angular/compiler": "^19.2.18",
|
||||
"@angular/core": "^19.2.18",
|
||||
"@angular/forms": "^19.2.18",
|
||||
"@angular/platform-browser": "^19.2.18",
|
||||
"@angular/platform-browser-dynamic": "^19.2.18",
|
||||
"@angular/router": "^19.2.18",
|
||||
"@angular/common": "^19.2.20",
|
||||
"@angular/compiler": "^19.2.20",
|
||||
"@angular/core": "^19.2.20",
|
||||
"@angular/forms": "^19.2.20",
|
||||
"@angular/platform-browser": "^19.2.20",
|
||||
"@angular/platform-browser-dynamic": "^19.2.20",
|
||||
"@angular/router": "^19.2.20",
|
||||
"@cds/core": "^6.15.1",
|
||||
"@clr/angular": "file:libraries/clr-angular-17.9.0.tgz",
|
||||
"@clr/icons": "^13.0.2",
|
||||
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
|
||||
"@handsontable/angular-wrapper": "16.0.1",
|
||||
"@sasjs/adapter": "^4.16.3",
|
||||
"@handsontable/angular-wrapper": "^17.1.0",
|
||||
"@sasjs/adapter": "^4.17.0",
|
||||
"@sasjs/utils": "^3.5.3",
|
||||
"@sheet/crypto": "file:libraries/sheet-crypto.tgz",
|
||||
"@types/d3-graphviz": "^2.6.7",
|
||||
@@ -61,8 +65,9 @@
|
||||
"crypto-browserify": "^3.12.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"d3-graphviz": "^5.0.2",
|
||||
"exceljs": "^4.4.0",
|
||||
"fs-extra": "^7.0.1",
|
||||
"handsontable": "^16.0.1",
|
||||
"handsontable": "^17.1.0",
|
||||
"https-browserify": "1.0.0",
|
||||
"hyperformula": "^2.5.0",
|
||||
"iconv-lite": "^0.5.0",
|
||||
@@ -86,18 +91,18 @@
|
||||
"zone.js": "~0.15.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^19.2.19",
|
||||
"@angular-devkit/build-angular": "^19.2.24",
|
||||
"@angular-eslint/builder": "19.8.1",
|
||||
"@angular-eslint/eslint-plugin": "19.8.1",
|
||||
"@angular-eslint/eslint-plugin-template": "19.8.1",
|
||||
"@angular-eslint/schematics": "19.8.1",
|
||||
"@angular-eslint/template-parser": "19.8.1",
|
||||
"@angular/cli": "^19.2.19",
|
||||
"@angular/compiler-cli": "^19.2.18",
|
||||
"@angular/cli": "^19.2.24",
|
||||
"@angular/compiler-cli": "^19.2.20",
|
||||
"@babel/plugin-proposal-private-methods": "^7.18.6",
|
||||
"@compodoc/compodoc": "^1.1.21",
|
||||
"@compodoc/compodoc": "^1.2.1",
|
||||
"@cypress/webpack-preprocessor": "^5.17.1",
|
||||
"@lhci/cli": "^0.12.0",
|
||||
"@lhci/cli": "^0.15.1",
|
||||
"@types/core-js": "^2.5.5",
|
||||
"@types/crypto-js": "^4.2.1",
|
||||
"@types/es6-shim": "^0.31.39",
|
||||
@@ -105,15 +110,15 @@
|
||||
"@types/lodash-es": "^4.17.3",
|
||||
"@types/marked": "^4.3.0",
|
||||
"@types/node": "12.20.50",
|
||||
"@typescript-eslint/eslint-plugin": "^5.29.0",
|
||||
"@typescript-eslint/parser": "^5.29.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.31.1",
|
||||
"@typescript-eslint/parser": "8.31.1",
|
||||
"core-js": "^2.5.4",
|
||||
"cypress": "12.17.1",
|
||||
"cypress": "^15.14.2",
|
||||
"cypress-file-upload": "^5.0.8",
|
||||
"cypress-plugin-tab": "^1.0.5",
|
||||
"cypress-real-events": "^1.8.1",
|
||||
"es6-shim": "^0.35.5",
|
||||
"eslint": "^8.33.0",
|
||||
"eslint": "8.57.1",
|
||||
"git-describe": "^4.0.4",
|
||||
"jasmine-core": "~5.1.2",
|
||||
"karma": "~6.4.3",
|
||||
@@ -125,6 +130,7 @@
|
||||
"lodash-es": "^4.17.21",
|
||||
"mochawesome": "^7.1.3",
|
||||
"mutationobserver-shim": "^0.3.3",
|
||||
"prettier": "3.8.4",
|
||||
"replace-in-file": "^6.3.5",
|
||||
"rimraf": "3.0.2",
|
||||
"ts-loader": "^9.2.8",
|
||||
@@ -132,5 +138,10 @@
|
||||
"typescript": "~5.8.3",
|
||||
"wait-on": "^6.0.1",
|
||||
"watch": "^1.0.2"
|
||||
},
|
||||
"overrides": {
|
||||
"ajv": "8.18.0",
|
||||
"uuid": "11.1.1",
|
||||
"lighthouse": "13.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'fs'
|
||||
import { resolve, join } from 'path'
|
||||
import { createRequire } from 'module'
|
||||
|
||||
/**
|
||||
* Generate static SVG assets + an SCSS partial that re-applies HOT v17 classic
|
||||
* theme icons via real URLs (not data: URIs).
|
||||
*
|
||||
* Why: deployed app runs under CSP `img-src 'self'`. HOT v17's classic theme
|
||||
* embeds icons as `data:image/svg+xml,...` in `-webkit-mask-image` rules, which
|
||||
* the CSP blocks. We switch to `ht-theme-classic-no-icons.min.css` and re-add
|
||||
* the icon rules pointing at same-origin SVG files emitted from this script.
|
||||
*
|
||||
* Inputs (HOT's own modules, so semantic names + selector list track upstream):
|
||||
* handsontable/themes/theme/classic → { classicTheme: { icons } }
|
||||
* handsontable/themes/static/variables/helpers/iconsMap → iconsMap(icons, themePrefix)
|
||||
*
|
||||
* Outputs:
|
||||
* client/src/assets/hot-icons/<kebab-name>.svg
|
||||
* client/src/_hot-icons.scss
|
||||
*
|
||||
* Idempotent: clears the output dir and rewrites both outputs each run.
|
||||
* Skips silently if handsontable isn't installed yet (pre-install runs).
|
||||
*/
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
const ASSETS_DIR = resolve('src/assets/hot-icons')
|
||||
const SCSS_OUT = resolve('src/_hot-icons.scss')
|
||||
const ASSET_URL_PREFIX = './assets/hot-icons/'
|
||||
|
||||
const themePath = resolve('node_modules/handsontable/themes/theme/classic.js')
|
||||
const mapPath = resolve('node_modules/handsontable/themes/static/variables/helpers/iconsMap.js')
|
||||
|
||||
if (!existsSync(themePath) || !existsSync(mapPath)) {
|
||||
console.log('skip: handsontable theme modules not found (likely pre-install run)')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const { classicTheme } = require(themePath)
|
||||
const { iconsMap } = require(mapPath)
|
||||
|
||||
const icons = classicTheme.icons
|
||||
const cssTemplate = iconsMap(icons, 'ht-theme-classic')
|
||||
|
||||
const kebab = (s) => s.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
|
||||
|
||||
rmSync(ASSETS_DIR, { recursive: true, force: true })
|
||||
mkdirSync(ASSETS_DIR, { recursive: true })
|
||||
|
||||
const writeMap = {}
|
||||
for (const [name, dataUri] of Object.entries(icons)) {
|
||||
if (typeof dataUri !== 'string' || !dataUri.startsWith('data:image/svg+xml')) continue
|
||||
const decoded = decodeURIComponent(dataUri.replace(/^data:image\/svg\+xml(;charset=utf-8)?,/, ''))
|
||||
const fname = kebab(name) + '.svg'
|
||||
writeFileSync(join(ASSETS_DIR, fname), decoded)
|
||||
writeMap[dataUri] = ASSET_URL_PREFIX + fname
|
||||
}
|
||||
|
||||
let scss = cssTemplate
|
||||
for (const [uri, url] of Object.entries(writeMap)) {
|
||||
scss = scss.split(`url("${uri}")`).join(`url("${url}")`)
|
||||
}
|
||||
|
||||
const header = '/* Auto-generated by scripts/gen-hot-icons.mjs — do not edit by hand.\n' +
|
||||
' Regenerated on postinstall; rerun manually via `node scripts/gen-hot-icons.mjs`. */\n\n'
|
||||
|
||||
writeFileSync(SCSS_OUT, header + scss + '\n')
|
||||
|
||||
console.log(`hot-icons: wrote ${Object.keys(writeMap).length} SVGs + ${SCSS_OUT}`)
|
||||
@@ -0,0 +1,59 @@
|
||||
import { readFileSync, writeFileSync, statSync, rmSync, existsSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
|
||||
/**
|
||||
* Remove Clarity's Metropolis @font-face blocks from clr-ui.min.css.
|
||||
*
|
||||
* Why: Clarity ships Metropolis as base64 data: URLs. The deployed app
|
||||
* runs under CSP `default-src 'self'` (no data: font-src), so every page
|
||||
* logs a font-load failure for each weight. Firefox preemptively
|
||||
* validates every parsed src against CSP even when a later @font-face
|
||||
* supersedes the rule at render time, so the only way to silence the
|
||||
* console is to remove the offending blocks from the parsed CSS.
|
||||
*
|
||||
* Our styles.scss declares the same family/weight/style with same-origin
|
||||
* .woff files, so removing Clarity's blocks entirely is safe and leaves
|
||||
* Metropolis fully functional.
|
||||
*
|
||||
* Idempotent: matches by font-family, so works on a fresh install or a
|
||||
* file that's already been stripped on a previous run.
|
||||
*/
|
||||
const target = resolve('node_modules/@clr/ui/clr-ui.min.css')
|
||||
|
||||
let css
|
||||
try {
|
||||
css = readFileSync(target, 'utf8')
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
console.log(`skip: ${target} not found (likely pre-install run)`)
|
||||
process.exit(0)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
const sizeBefore = statSync(target).size
|
||||
const blockRe = /@font-face\{[^}]*Metropolis[^}]*\}/g
|
||||
const matches = css.match(blockRe) ?? []
|
||||
|
||||
if (matches.length === 0) {
|
||||
console.log(`already stripped: ${target}`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const stripped = css.replace(blockRe, '')
|
||||
writeFileSync(target, stripped)
|
||||
const sizeAfter = Buffer.byteLength(stripped)
|
||||
console.log(
|
||||
`removed ${matches.length} Metropolis @font-face block(s) from clr-ui.min.css ` +
|
||||
`(${sizeBefore} -> ${sizeAfter} bytes, saved ${sizeBefore - sizeAfter})`
|
||||
)
|
||||
|
||||
// Webpack 5's persistent cache treats node_modules as immutable
|
||||
// (snapshot.module.managedPaths default), so in-place edits don't
|
||||
// invalidate cached entries. Drop the Angular build cache so the next
|
||||
// build re-reads our stripped clr-ui.min.css.
|
||||
const cacheDir = resolve('.angular/cache')
|
||||
if (existsSync(cacheDir)) {
|
||||
rmSync(cacheDir, { recursive: true, force: true })
|
||||
console.log(`cleared ${cacheDir} (webpack persistent cache)`)
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/* Auto-generated by scripts/gen-hot-icons.mjs — do not edit by hand.
|
||||
Regenerated on postinstall; rerun manually via `node scripts/gen-hot-icons.mjs`. */
|
||||
|
||||
[class*=ht-theme-classic] .htDropdownMenu table tbody tr td.htSubmenu .htItemWrapper::after,
|
||||
[class*=ht-theme-classic] .htContextMenu table tbody tr td.htSubmenu .htItemWrapper::after,
|
||||
[class*=ht-theme-classic] .htFiltersConditionsMenu table tbody tr td.htSubmenu .htItemWrapper::after,
|
||||
[class*=ht-theme-classic] .pika-single .pika-next {
|
||||
width: var(--ht-icon-size);
|
||||
height: var(--ht-icon-size);
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-image: url("./assets/hot-icons/arrow-right.svg");
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
[class*=ht-theme-classic] .pika-single .pika-prev {
|
||||
width: var(--ht-icon-size);
|
||||
height: var(--ht-icon-size);
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-image: url("./assets/hot-icons/arrow-left.svg");
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
[class*=ht-theme-classic] .ht-page-size-section__select-wrapper::after {
|
||||
width: var(--ht-icon-size);
|
||||
height: var(--ht-icon-size);
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-image: url("./assets/hot-icons/arrow-down.svg");
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
[class*=ht-theme-classic] .changeType::before {
|
||||
width: var(--ht-icon-size);
|
||||
height: var(--ht-icon-size);
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-image: url("./assets/hot-icons/select-arrow.svg");
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
[class*=ht-theme-classic] .htUISelectCaption::after,
|
||||
.htAutocompleteArrow::after {
|
||||
width: var(--ht-icon-size);
|
||||
height: var(--ht-icon-size);
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-image: url("./assets/hot-icons/select-arrow.svg");
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
[class*=ht-theme-classic] .columnSorting.sortAction.ascending::before {
|
||||
width: var(--ht-icon-size);
|
||||
height: var(--ht-icon-size);
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-image: url("./assets/hot-icons/arrow-narrow-up.svg");
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
[class*=ht-theme-classic] .columnSorting.sortAction.descending::before {
|
||||
width: var(--ht-icon-size);
|
||||
height: var(--ht-icon-size);
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-image: url("./assets/hot-icons/arrow-narrow-down.svg");
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
[class*=ht-theme-classic] .ht-page-navigation-section .ht-page-first::before {
|
||||
width: var(--ht-icon-size);
|
||||
height: var(--ht-icon-size);
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-image: url("./assets/hot-icons/arrow-left-with-bar.svg");
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
[class*=ht-theme-classic] [dir="rtl"] .ht-page-navigation-section .ht-page-first::before {
|
||||
width: var(--ht-icon-size);
|
||||
height: var(--ht-icon-size);
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-image: url("./assets/hot-icons/arrow-right-with-bar.svg");
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
[class*=ht-theme-classic] .ht-page-navigation-section .ht-page-prev::before {
|
||||
width: var(--ht-icon-size);
|
||||
height: var(--ht-icon-size);
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-image: url("./assets/hot-icons/arrow-left.svg");
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
[class*=ht-theme-classic] [dir="rtl"] .ht-page-navigation-section .ht-page-prev::before {
|
||||
width: var(--ht-icon-size);
|
||||
height: var(--ht-icon-size);
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-image: url("./assets/hot-icons/arrow-right.svg");
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
[class*=ht-theme-classic] .ht-page-navigation-section .ht-page-next::before {
|
||||
width: var(--ht-icon-size);
|
||||
height: var(--ht-icon-size);
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-image: url("./assets/hot-icons/arrow-right.svg");
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
[class*=ht-theme-classic] [dir="rtl"] .ht-page-navigation-section .ht-page-next::before {
|
||||
width: var(--ht-icon-size);
|
||||
height: var(--ht-icon-size);
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-image: url("./assets/hot-icons/arrow-left.svg");
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
[class*=ht-theme-classic] .ht-page-navigation-section .ht-page-last::before {
|
||||
width: var(--ht-icon-size);
|
||||
height: var(--ht-icon-size);
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-image: url("./assets/hot-icons/arrow-right-with-bar.svg");
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
[class*=ht-theme-classic] [dir="rtl"] .ht-page-navigation-section .ht-page-last::before {
|
||||
width: var(--ht-icon-size);
|
||||
height: var(--ht-icon-size);
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-image: url("./assets/hot-icons/arrow-left-with-bar.svg");
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
[class*=ht-theme-classic] .htDropdownMenu table tbody tr td .htItemWrapper span.selected::after,
|
||||
[class*=ht-theme-classic] .htContextMenu table tbody tr td .htItemWrapper span.selected::after,
|
||||
[class*=ht-theme-classic] .htFiltersConditionsMenu table tbody tr td .htItemWrapper span.selected::after {
|
||||
width: var(--ht-icon-size);
|
||||
height: var(--ht-icon-size);
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-image: url("./assets/hot-icons/check.svg");
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
[class*=ht-theme-classic] .htCheckboxRendererInput {
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
[class*=ht-theme-classic] .htCheckboxRendererInput::after {
|
||||
width: var(--ht-icon-size);
|
||||
height: var(--ht-icon-size);
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-image: url("./assets/hot-icons/checkbox.svg");
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
[class*=ht-theme-classic] th.beforeHiddenColumn::after {
|
||||
width: var(--ht-icon-size);
|
||||
height: var(--ht-icon-size);
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-image: url("./assets/hot-icons/caret-hidden-left.svg");
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
[class*=ht-theme-classic] th.afterHiddenColumn::before {
|
||||
width: var(--ht-icon-size);
|
||||
height: var(--ht-icon-size);
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-image: url("./assets/hot-icons/caret-hidden-right.svg");
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
[class*=ht-theme-classic] th.beforeHiddenRow::after {
|
||||
width: var(--ht-icon-size);
|
||||
height: var(--ht-icon-size);
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-image: url("./assets/hot-icons/caret-hidden-up.svg");
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
[class*=ht-theme-classic] th.afterHiddenRow::before {
|
||||
width: var(--ht-icon-size);
|
||||
height: var(--ht-icon-size);
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-image: url("./assets/hot-icons/caret-hidden-down.svg");
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
[class*=ht-theme-classic] .collapsibleIndicator::before,
|
||||
[class*=ht-theme-classic] .ht_nestingButton::before {
|
||||
width: var(--ht-icon-size);
|
||||
height: var(--ht-icon-size);
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-image: url("./assets/hot-icons/collapse-off.svg");
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
[class*=ht-theme-classic] .collapsibleIndicator.collapsed::before,
|
||||
[class*=ht-theme-classic] .ht_nestingButton.ht_nestingExpand::before {
|
||||
width: var(--ht-icon-size);
|
||||
height: var(--ht-icon-size);
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-image: url("./assets/hot-icons/collapse-on.svg");
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
[class*=ht-theme-classic] .htUIRadio > input[type="radio"]::after {
|
||||
width: var(--ht-icon-size);
|
||||
height: var(--ht-icon-size);
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-image: url("./assets/hot-icons/radio.svg");
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
[class*=ht-theme-classic] .ht-multi-select-chip-remove::before {
|
||||
width: var(--ht-icon-size);
|
||||
height: var(--ht-icon-size);
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-image: url("./assets/hot-icons/chip-close.svg");
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
[class*=ht-theme-classic] .ht-notification__close::before {
|
||||
width: var(--ht-icon-size);
|
||||
height: var(--ht-icon-size);
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-image: url("./assets/hot-icons/chip-close.svg");
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
[class*=ht-theme-classic] .ht-multi-select-editor-search-icon {
|
||||
width: var(--ht-icon-size);
|
||||
height: var(--ht-icon-size);
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-image: url("./assets/hot-icons/search.svg");
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
[class*=ht-theme-classic] .ht-multi-select-editor-item-selected input::after {
|
||||
width: var(--ht-icon-size);
|
||||
height: var(--ht-icon-size);
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-image: url("./assets/hot-icons/checkbox.svg");
|
||||
background-color: currentColor;
|
||||
}
|
||||
@@ -55,7 +55,12 @@ export interface HandsontableStaticConfig {
|
||||
* Cached viyaApi collections, search and selected endpoint
|
||||
*/
|
||||
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
|
||||
dcLib: string
|
||||
xlmaps: XLMapListItem[]
|
||||
@@ -71,6 +76,7 @@ export const globals: {
|
||||
[key: string]: any
|
||||
} = {
|
||||
embed: false,
|
||||
vaApplyMode: 'live',
|
||||
rootParam: <string>'',
|
||||
dcLib: '',
|
||||
xlmaps: [],
|
||||
|
||||
@@ -127,9 +127,10 @@
|
||||
"
|
||||
(click)="toggleSidebar()"
|
||||
type="button"
|
||||
aria-label="Toggle sidebar"
|
||||
class="cursor-pointer select-none ml-10 d-flex clr-justify-content-center clr-align-items-center"
|
||||
>
|
||||
<clr-icon size="24" shape="tree-view"></clr-icon>
|
||||
<clr-icon size="24" shape="tree-view" aria-hidden="true"></clr-icon>
|
||||
</div>
|
||||
|
||||
<div class="logo d-flex clr-align-items-center">
|
||||
@@ -255,6 +256,26 @@
|
||||
|
||||
<!-- <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>
|
||||
|
||||
<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/shapes/all-shapes'
|
||||
import { globals } from './_globals'
|
||||
import { parseEmbedParam } from './shared/utils/parse-embed-param'
|
||||
import moment from 'moment'
|
||||
import { EventService } from './services/event.service'
|
||||
import { AppService } from './services/app.service'
|
||||
@@ -145,13 +146,10 @@ export class AppComponent {
|
||||
})
|
||||
|
||||
const hashQuery = window.location.hash.split('?')[1]
|
||||
if (hashQuery) {
|
||||
const embedParam = new URLSearchParams(hashQuery).get('embed')
|
||||
if (embedParam !== null) {
|
||||
const isEmbed = embedParam !== 'false'
|
||||
globals.embed = isEmbed
|
||||
this.embed = isEmbed
|
||||
}
|
||||
if (hashQuery && new URLSearchParams(hashQuery).get('embed') !== null) {
|
||||
const embed = parseEmbedParam(hashQuery)
|
||||
globals.embed = embed
|
||||
this.embed = embed
|
||||
}
|
||||
|
||||
this.subscribeToShowAbortModal()
|
||||
@@ -209,6 +207,7 @@ export class AppComponent {
|
||||
dcPath: getAppAttribute('dcPath') || '',
|
||||
debug: getAppAttribute('debug') === 'true' || false,
|
||||
useComputeApi: this.parseComputeApi(getAppAttribute('useComputeApi')),
|
||||
runAsTask: getAppAttribute('runAsTask') === 'true' || false,
|
||||
contextName: getAppAttribute('contextName') || '',
|
||||
hotLicenceKey: getAppAttribute('hotLicenceKey') || ''
|
||||
}
|
||||
@@ -353,4 +352,23 @@ export class AppComponent {
|
||||
public openLicencingPage() {
|
||||
this.router.navigateByUrl('/licensing/update')
|
||||
}
|
||||
|
||||
/** "lib.table" of the last-opened editor table (for the VA embed back button). */
|
||||
get vaEditorLibds(): string {
|
||||
const e = globals.editor
|
||||
return e?.library && e?.table ? `${e.library}.${e.table}` : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* VA embed only: returns to the editor for the last-opened table. Lets users
|
||||
* who land on the stage/approve/review pages get back without knowing DC's
|
||||
* page paths. `globals.embed` (set at app init) keeps the editor in VA mode.
|
||||
*/
|
||||
public backToEditor(): void {
|
||||
if (this.vaEditorLibds) {
|
||||
this.router.navigate(['/editor/' + this.vaEditorLibds], {
|
||||
queryParams: { embed: 'va' }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
-1
@@ -1,5 +1,4 @@
|
||||
declare module 'save-svg-as-png'
|
||||
declare module 'numbro/dist/languages.min'
|
||||
declare interface Navigator {
|
||||
msSaveBlob: (blob: any, defaultName?: string) => boolean
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="content-area position-relative">
|
||||
<main class="content-area position-relative">
|
||||
<div class="clr-row">
|
||||
<!-- T&C section -->
|
||||
<div *ngIf="step === 0" id="TCS" class="card">
|
||||
@@ -97,4 +97,4 @@
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -373,7 +373,7 @@ export class AutomaticComponent implements OnInit {
|
||||
let contextname = `&_contextname=${params.contextName}`
|
||||
let admin = `&admin=${params.admin}`
|
||||
let dcPath = `&dcpath=${params.dcPath}`
|
||||
let debug = `&_debug=131`
|
||||
let debug = this.sasService.getDebugUrlParam()
|
||||
|
||||
let programUrl =
|
||||
serverUrl +
|
||||
|
||||
@@ -251,7 +251,7 @@ export class ManualComponent implements OnInit {
|
||||
this.selectedAdminGroup +
|
||||
'&DCPATH=' +
|
||||
this.dcPath +
|
||||
'&_debug=131'
|
||||
this.sasService.getDebugUrlParam()
|
||||
|
||||
window.open(url, '_blank')
|
||||
|
||||
|
||||
@@ -177,45 +177,45 @@
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<clr-textarea-container class="date-field" *ngSwitchCase="'time'">
|
||||
<textarea
|
||||
clrTextarea
|
||||
(paste)="recordInputPaste($event)"
|
||||
(input)="recordInputChange($event, col.key)"
|
||||
<div class="date-field" *ngSwitchCase="'intl-time'">
|
||||
<input
|
||||
type="time"
|
||||
step="1"
|
||||
class="date-input"
|
||||
[attr.aria-label]="col.key"
|
||||
[class.invalid-data]="
|
||||
currentRecordInvalidCols.includes(col.key)
|
||||
"
|
||||
[rows]="col.value.length > 80 ? 6 : 1"
|
||||
[(ngModel)]="currentRecord[col.key]"
|
||||
[class.not-char]="
|
||||
currentRecordValidator?.getRule(col.key)?.type
|
||||
"
|
||||
></textarea>
|
||||
<clr-control-helper>HH:mm:ss</clr-control-helper>
|
||||
</clr-textarea-container>
|
||||
[value]="currentRecord[col.key]"
|
||||
(change)="recordTimeChange($event, col.key)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="date-field" *ngSwitchCase="'date'">
|
||||
<textarea
|
||||
clrTextarea
|
||||
(paste)="recordInputPaste($event)"
|
||||
(input)="recordInputChange($event, col.key)"
|
||||
<div class="date-field" *ngSwitchCase="'intl-datetime'">
|
||||
<input
|
||||
type="datetime-local"
|
||||
step="1"
|
||||
class="date-input"
|
||||
[attr.aria-label]="col.key"
|
||||
[class.invalid-data]="
|
||||
currentRecordInvalidCols.includes(col.key)
|
||||
"
|
||||
rows="1"
|
||||
cols="auto"
|
||||
class="not-char"
|
||||
[(ngModel)]="currentRecord[col.key]"
|
||||
></textarea>
|
||||
<clr-date-container class="date-picker">
|
||||
<input
|
||||
type="date"
|
||||
name="date"
|
||||
class="d-none"
|
||||
(clrDateChange)="recordDateChange($event, col.key)"
|
||||
clrDate
|
||||
/>
|
||||
</clr-date-container>
|
||||
[value]="toNativeDatetime(currentRecord[col.key])"
|
||||
(change)="recordDatetimeChange($event, col.key)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="date-field" *ngSwitchCase="'intl-date'">
|
||||
<input
|
||||
type="date"
|
||||
class="date-input"
|
||||
[attr.aria-label]="col.key"
|
||||
[class.invalid-data]="
|
||||
currentRecordInvalidCols.includes(col.key)
|
||||
"
|
||||
[value]="currentRecord[col.key]"
|
||||
(change)="recordDateChange($event, col.key)"
|
||||
/>
|
||||
</div>
|
||||
<div *ngSwitchCase="'autocomplete'">
|
||||
<ng-container
|
||||
@@ -255,6 +255,7 @@
|
||||
type="button"
|
||||
class="btn btn-outline focusable"
|
||||
tabindex="0"
|
||||
aria-label="Previous record"
|
||||
(click)="onPreviousRecordClick()"
|
||||
[disabled]="currentRecordInvalidCols.length > 0"
|
||||
>
|
||||
@@ -267,6 +268,7 @@
|
||||
type="button"
|
||||
class="btn btn-outline focusable"
|
||||
tabindex="0"
|
||||
aria-label="Next record"
|
||||
(click)="onNextRecordClick()"
|
||||
[disabled]="currentRecordInvalidCols.length > 0"
|
||||
>
|
||||
|
||||
@@ -2,12 +2,12 @@ import { KeyValue } from '@angular/common'
|
||||
import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
HostListener,
|
||||
Input,
|
||||
OnInit,
|
||||
Output,
|
||||
ViewEncapsulation
|
||||
} from '@angular/core'
|
||||
import moment from 'moment'
|
||||
import { ValidateFilterSASResponse } from 'src/app/models/sas/validate-filter.model'
|
||||
import { QueryClause } from 'src/app/models/TableData'
|
||||
import { HelperService } from 'src/app/services/helper.service'
|
||||
@@ -91,17 +91,69 @@ export class EditRecordComponent implements OnInit {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fired when date field in the record change
|
||||
* Function will parse date and format to string
|
||||
* @param date picker value
|
||||
* Fired when the native date picker (intl-date) changes. A native
|
||||
* `<input type="date">` value is always ISO `YYYY-MM-DD`, so it is stored
|
||||
* as-is. Mirrors recordTimeChange / recordDatetimeChange.
|
||||
* @param event native <input type="date"> change event
|
||||
* @param colKey column name (key)
|
||||
*/
|
||||
recordDateChange(date: Date, colKey: string) {
|
||||
let cellValidation = this.currentRecordValidator?.getRule(colKey)
|
||||
let format = cellValidation ? cellValidation.dateFormat : ''
|
||||
recordDateChange(event: Event, colKey: string) {
|
||||
const value = (event.target as HTMLInputElement).value // YYYY-MM-DD
|
||||
if (!value || !this.currentRecord) return
|
||||
this.currentRecord[colKey] = value
|
||||
this.revalidateRecordCol(colKey, value)
|
||||
}
|
||||
|
||||
if (this.currentRecord)
|
||||
this.currentRecord[colKey] = moment(date).format(format)
|
||||
/**
|
||||
* Stored `YYYY-MM-DD HH:mm:ss` → native `<input type="datetime-local">` value
|
||||
* `YYYY-MM-DDTHH:mm:ss`, so the picker opens at the current value.
|
||||
*/
|
||||
toNativeDatetime(value: any): string {
|
||||
if (typeof value !== 'string') return ''
|
||||
return value.includes(' ') ? value.replace(' ', 'T') : value
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-runs validation for a column after a native picker change (the picker
|
||||
* handlers set the value programmatically, which does not fire the textarea's
|
||||
* input handler). Mirrors recordInputChange's validate + state update.
|
||||
*/
|
||||
private revalidateRecordCol(colName: string, value: any) {
|
||||
const colRules = this.currentRecordValidator?.getRule(colName)
|
||||
this.validateRecordCol(colRules, value).then((valid: boolean) =>
|
||||
this.updateValidationState(colName, valid)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fired when the native time picker (intl-time) changes. Stores 24h
|
||||
* `HH:mm:ss`, padding the seconds the browser may omit. Mirrors the
|
||||
* IntlDatetimeEditor conversion used by the HOT grid.
|
||||
* @param event native <input type="time"> change event
|
||||
* @param colKey column name (key)
|
||||
*/
|
||||
recordTimeChange(event: Event, colKey: string) {
|
||||
const value = (event.target as HTMLInputElement).value // HH:mm[:ss]
|
||||
if (!value || !this.currentRecord) return
|
||||
const stored = value.length === 5 ? `${value}:00` : value
|
||||
this.currentRecord[colKey] = stored
|
||||
this.revalidateRecordCol(colKey, stored)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fired when the native datetime picker (intl-datetime) changes. Converts the
|
||||
* native `YYYY-MM-DDTHH:mm[:ss]` into SAS' stored `YYYY-MM-DD HH:mm:ss`,
|
||||
* padding seconds. Mirrors the IntlDatetimeEditor conversion used by the grid.
|
||||
* @param event native <input type="datetime-local"> change event
|
||||
* @param colKey column name (key)
|
||||
*/
|
||||
recordDatetimeChange(event: Event, colKey: string) {
|
||||
const value = (event.target as HTMLInputElement).value // YYYY-MM-DDTHH:mm[:ss]
|
||||
if (!value || !this.currentRecord) return
|
||||
const [date, time] = value.split('T')
|
||||
const stored = `${date} ${time.length === 5 ? `${time}:00` : time}`
|
||||
this.currentRecord[colKey] = stored
|
||||
this.revalidateRecordCol(colKey, stored)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,6 +172,16 @@ export class EditRecordComponent implements OnInit {
|
||||
this.onRecordEditClose.emit()
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the modal on Escape (cancel, like the Close button). A native picker
|
||||
* swallows the first Escape to dismiss itself, so the modal only closes once
|
||||
* focus is back on the form.
|
||||
*/
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscapeKey() {
|
||||
this.closeRecordEdit()
|
||||
}
|
||||
|
||||
/**
|
||||
* Emitting output event when dropdown (autocomplete) input in any col change
|
||||
* @param colName column name (key)
|
||||
@@ -233,8 +295,9 @@ export class EditRecordComponent implements OnInit {
|
||||
if (obj.data === key) {
|
||||
if (
|
||||
obj.type === 'numeric' ||
|
||||
obj.type === 'date' ||
|
||||
obj.type === 'time'
|
||||
obj.type === 'intl-date' ||
|
||||
obj.type === 'intl-time' ||
|
||||
obj.type === 'intl-datetime'
|
||||
) {
|
||||
type = 'N'
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="content-area d-flex clr-flex-column">
|
||||
<main class="content-area d-flex clr-flex-column">
|
||||
<clr-modal
|
||||
appFileDrop
|
||||
(fileOver)="fileOverBase($event)"
|
||||
@@ -172,14 +172,23 @@
|
||||
class="btn icon-collapse btn-sm btn-icon btn-dimmed"
|
||||
[routerLink]="['/home']"
|
||||
>
|
||||
<clr-icon shape="caret" dir="left" size="20"></clr-icon>
|
||||
<clr-icon
|
||||
aria-hidden="true"
|
||||
shape="caret"
|
||||
dir="left"
|
||||
size="20"
|
||||
></clr-icon>
|
||||
<span class="text">Back to table selection</span>
|
||||
</span>
|
||||
<span
|
||||
(click)="viewboxManager()"
|
||||
class="btn icon-collapse btn-sm btn-icon btn-dimmed viewbox-open"
|
||||
>
|
||||
<clr-icon shape="view-cards" size="20"></clr-icon>
|
||||
<clr-icon
|
||||
aria-hidden="true"
|
||||
shape="view-cards"
|
||||
size="20"
|
||||
></clr-icon>
|
||||
<span class="text">Viewboxes</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -207,6 +216,7 @@
|
||||
|
||||
<clr-icon
|
||||
*ngIf="libdsParsed.tableName.includes('-FC')"
|
||||
aria-hidden="true"
|
||||
shape="bolt"
|
||||
class="color-yellow"
|
||||
></clr-icon>
|
||||
@@ -251,7 +261,7 @@
|
||||
class="btnView btn icon-collapse btn-sm btn-icon btn-block btn-dimmed"
|
||||
(click)="openQb()"
|
||||
>
|
||||
<clr-icon shape="filter"></clr-icon>
|
||||
<clr-icon aria-hidden="true" shape="filter"></clr-icon>
|
||||
<span class="text">Filter</span>
|
||||
</button>
|
||||
|
||||
@@ -260,7 +270,7 @@
|
||||
class="btn icon-collapse btn-sm btn-primary btn-block"
|
||||
(click)="editTable()"
|
||||
>
|
||||
<clr-icon shape="note"></clr-icon>
|
||||
<clr-icon aria-hidden="true" shape="note"></clr-icon>
|
||||
<span class="text">Edit</span>
|
||||
</button>
|
||||
|
||||
@@ -270,18 +280,20 @@
|
||||
type="button"
|
||||
class="btn icon-collapse btn-sm btn-success btn-block mr-0"
|
||||
>
|
||||
<clr-icon shape="upload"></clr-icon>
|
||||
<clr-icon aria-hidden="true" shape="upload"></clr-icon>
|
||||
<span class="text">Upload</span>
|
||||
</button>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="!hotTable.readOnly && !uploadPreview">
|
||||
<ng-container
|
||||
*ngIf="!hotTable.readOnly && !uploadPreview && !isVaEmbed"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-icon btn-outline-danger"
|
||||
(click)="cancelEdit()"
|
||||
>
|
||||
<clr-icon shape="times"></clr-icon>
|
||||
<clr-icon aria-hidden="true" shape="times"></clr-icon>
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
|
||||
@@ -294,7 +306,8 @@
|
||||
[class.dc-locked-control]="restrictions.restrictAddRow"
|
||||
(click)="!restrictions.restrictAddRow ? addRow() : ''"
|
||||
>
|
||||
<clr-icon shape="plus" size="16"></clr-icon>Add Row
|
||||
<clr-icon aria-hidden="true" shape="plus" size="16"></clr-icon
|
||||
>Add Row
|
||||
</button>
|
||||
|
||||
<clr-tooltip-content
|
||||
@@ -319,7 +332,8 @@
|
||||
class="btn btn-sm btn-primary"
|
||||
(click)="checkSave()"
|
||||
>
|
||||
<clr-icon shape="check" size="20"></clr-icon>Submit
|
||||
<clr-icon aria-hidden="true" shape="check" size="20"></clr-icon
|
||||
>Submit
|
||||
</button>
|
||||
</ng-container>
|
||||
|
||||
@@ -329,7 +343,7 @@
|
||||
class="btn btn-sm btn-icon btn-outline-danger btn-upload-preview"
|
||||
(click)="discardSourceFile = true"
|
||||
>
|
||||
<clr-icon shape="times"></clr-icon>
|
||||
<clr-icon aria-hidden="true" shape="times"></clr-icon>
|
||||
<span>Discard file</span>
|
||||
</button>
|
||||
|
||||
@@ -339,7 +353,7 @@
|
||||
class="btn btn-sm btn-primary btn-upload-preview"
|
||||
(click)="manualFileEditModal = true"
|
||||
>
|
||||
<clr-icon shape="note"></clr-icon>
|
||||
<clr-icon aria-hidden="true" shape="note"></clr-icon>
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
|
||||
@@ -349,7 +363,7 @@
|
||||
(click)="submitExcel()"
|
||||
[clrLoading]="uploadLoading"
|
||||
>
|
||||
<clr-icon shape="check" size="20"></clr-icon>
|
||||
<clr-icon aria-hidden="true" shape="check" size="20"></clr-icon>
|
||||
Submit
|
||||
</button>
|
||||
</ng-container>
|
||||
@@ -382,7 +396,11 @@
|
||||
|
||||
<ng-container *ngIf="getdataError">
|
||||
<span>
|
||||
<clr-icon shape="error-standard" class="error-icon"></clr-icon>
|
||||
<clr-icon
|
||||
aria-hidden="true"
|
||||
shape="error-standard"
|
||||
class="error-icon"
|
||||
></clr-icon>
|
||||
</span>
|
||||
|
||||
<div class="mt-10">
|
||||
@@ -419,7 +437,9 @@
|
||||
|
||||
<div>
|
||||
<clr-tooltip
|
||||
*ngIf="tableTrue && !restrictions.removeAddRecordButton"
|
||||
*ngIf="
|
||||
tableTrue && !restrictions.removeAddRecordButton && !isVaEmbed
|
||||
"
|
||||
>
|
||||
<button
|
||||
clrTooltipTrigger
|
||||
@@ -430,7 +450,7 @@
|
||||
!restrictions.restrictAddRow ? addRecordButtonClick() : ''
|
||||
"
|
||||
>
|
||||
<clr-icon shape="plus" size="16"></clr-icon>
|
||||
<clr-icon aria-hidden="true" shape="plus" size="16"></clr-icon>
|
||||
Add Record
|
||||
</button>
|
||||
|
||||
@@ -450,6 +470,69 @@
|
||||
>
|
||||
</clr-tooltip-content>
|
||||
</clr-tooltip>
|
||||
|
||||
<!-- VA data-driven content mode: single Submit below the grid.
|
||||
Routes through the same checkSave() pipeline as the normal
|
||||
editor (validation + approval modal + saveTable). -->
|
||||
<ng-container *ngIf="isVaEmbed">
|
||||
<!-- Single VA action bar: Submit | Auto-apply toggle | filter
|
||||
status (pending/loading), all on one line. -->
|
||||
<div class="va-filter-controls w-100 mt-2-i">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-primary"
|
||||
[disabled]="submitLoading"
|
||||
(click)="checkSave()"
|
||||
title="Submit changes for approval"
|
||||
>
|
||||
<clr-icon
|
||||
aria-hidden="true"
|
||||
shape="check"
|
||||
size="20"
|
||||
></clr-icon>
|
||||
Submit
|
||||
</button>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- Persistent live region so screen readers announce the
|
||||
pending/loading transitions as they happen. -->
|
||||
<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
|
||||
*ngIf="
|
||||
licenceState.value.editor_rows_allowed !== Infinity &&
|
||||
@@ -686,7 +769,7 @@
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div class="modal z-index-highest" *ngIf="nullVariables">
|
||||
<div class="modal-dialog" role="dialog" aria-hidden="true">
|
||||
@@ -873,3 +956,17 @@
|
||||
</app-dataset-info>
|
||||
|
||||
<app-viewboxes [(viewboxModal)]="viewboxes"></app-viewboxes>
|
||||
|
||||
<app-confirm-modal
|
||||
[open]="confirmModal.open"
|
||||
[title]="confirmModal.title"
|
||||
[message]="confirmModal.message"
|
||||
(result)="onConfirmModalResult($event)"
|
||||
></app-confirm-modal>
|
||||
|
||||
<app-bulk-validation-modal
|
||||
[open]="bulkValidation.active"
|
||||
[done]="bulkValidation.done"
|
||||
[total]="bulkValidation.total"
|
||||
(cancel)="cancelBulkValidation({ revert: true })"
|
||||
></app-bulk-validation-modal>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,22 @@
|
||||
/**
|
||||
* Model for the dynamic cell validation in the editor
|
||||
* (sending whole row to the backend service and recieving data for the cell dropdown)
|
||||
* Model for a row of `dynamic_values` returned by the getdynamiccolvals
|
||||
* service (the dropdown source for the edited cell itself).
|
||||
*/
|
||||
export interface DynamicCellValidation {
|
||||
DISPLAY_INDEX: number
|
||||
RAW_VALUE: string | number
|
||||
}
|
||||
|
||||
/**
|
||||
* Model for a row of `dynamic_extended_values` returned by the
|
||||
* getdynamiccolvals service (dropdown values for _other_ cells in the _same_
|
||||
* row, mapped via DISPLAY_INDEX).
|
||||
*/
|
||||
export interface DynamicExtendedCellValidation {
|
||||
DISPLAY_INDEX: number
|
||||
DISPLAY_TYPE: string
|
||||
DISPLAY_VALUE: string
|
||||
EXTRA_COL_NAME: string
|
||||
RAW_VALUE_CHAR: string
|
||||
RAW_VALUE_NUM: number
|
||||
FORCE_FLAG: number
|
||||
FORCED_VALUE: number
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import Handsontable from 'handsontable'
|
||||
import { makeNumberFormatRenderer } from './renderers.utils'
|
||||
|
||||
describe('makeNumberFormatRenderer', () => {
|
||||
it('renders a numeric cell as EUR currency without changing the value', () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
|
||||
const hot = new Handsontable(container, {
|
||||
data: [{ amt: 1025 }],
|
||||
columns: [
|
||||
{
|
||||
data: 'amt',
|
||||
type: 'numeric',
|
||||
renderer: makeNumberFormatRenderer(
|
||||
'{"style":"currency","currency":"EUR"}'
|
||||
)
|
||||
}
|
||||
],
|
||||
licenseKey: 'non-commercial-and-evaluation'
|
||||
})
|
||||
hot.render()
|
||||
|
||||
const td = hot.getCell(0, 0)
|
||||
// Display is formatted as currency...
|
||||
expect(td?.textContent).toContain('€')
|
||||
expect(td?.textContent).toContain('1,025')
|
||||
// ...but the stored value is untouched
|
||||
expect(hot.getDataAtCell(0, 0)).toEqual(1025)
|
||||
|
||||
hot.destroy()
|
||||
container.remove()
|
||||
})
|
||||
|
||||
it('is overridden by numbro numericFormat (why DcValidator clears it for NUMBER_FORMAT cols)', () => {
|
||||
// Regression note: on a `type: 'numeric'` column, a `numericFormat` makes
|
||||
// HOT re-render via numbro and drop our currency symbol. DcValidator clears
|
||||
// numericFormat on NUMBER_FORMAT columns so the Intl renderer wins.
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
|
||||
const hot = new Handsontable(container, {
|
||||
data: [{ amt: 1025 }],
|
||||
columns: [
|
||||
{
|
||||
data: 'amt',
|
||||
type: 'numeric',
|
||||
numericFormat: { pattern: '0,0', culture: 'en-US' },
|
||||
renderer: makeNumberFormatRenderer(
|
||||
'{"style":"currency","currency":"EUR"}'
|
||||
)
|
||||
}
|
||||
],
|
||||
licenseKey: 'non-commercial-and-evaluation'
|
||||
})
|
||||
hot.render()
|
||||
|
||||
const td = hot.getCell(0, 0)
|
||||
expect(td?.textContent).not.toContain('€')
|
||||
|
||||
hot.destroy()
|
||||
container.remove()
|
||||
})
|
||||
|
||||
it('falls back to a plain number when options JSON is invalid', () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
|
||||
const hot = new Handsontable(container, {
|
||||
data: [{ amt: 1025 }],
|
||||
columns: [
|
||||
{
|
||||
data: 'amt',
|
||||
type: 'numeric',
|
||||
renderer: makeNumberFormatRenderer('not json')
|
||||
}
|
||||
],
|
||||
licenseKey: 'non-commercial-and-evaluation'
|
||||
})
|
||||
hot.render()
|
||||
|
||||
const td = hot.getCell(0, 0)
|
||||
expect(td?.textContent).not.toContain('€')
|
||||
|
||||
hot.destroy()
|
||||
container.remove()
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,57 @@
|
||||
import Handsontable from 'handsontable'
|
||||
|
||||
/**
|
||||
* Builds a display-only HOT renderer that formats numeric cell values using
|
||||
* Intl.NumberFormat. The stored/submitted value is never changed — only the
|
||||
* rendered text. `ruleValue` is a JSON string of Intl.NumberFormat options
|
||||
* (e.g. '{"style":"currency","currency":"EUR","minimumFractionDigits":2}').
|
||||
*
|
||||
* Falls back to the plain text renderer when the JSON is invalid, the options
|
||||
* are rejected by Intl.NumberFormat, or the value is not a finite number.
|
||||
*/
|
||||
export const makeNumberFormatRenderer = (ruleValue?: string) => {
|
||||
let formatter: Intl.NumberFormat | null = null
|
||||
|
||||
try {
|
||||
const options = ruleValue ? JSON.parse(ruleValue) : {}
|
||||
formatter = new Intl.NumberFormat(window.navigator.language, options)
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`NUMBER_FORMAT - invalid Intl.NumberFormat options: ${ruleValue}`
|
||||
)
|
||||
formatter = null
|
||||
}
|
||||
|
||||
const baseRenderer = Handsontable.renderers.getRenderer('text')
|
||||
|
||||
return (
|
||||
instance: any,
|
||||
td: any,
|
||||
row: number,
|
||||
col: number,
|
||||
prop: string | number,
|
||||
value: any,
|
||||
cellProperties: any
|
||||
) => {
|
||||
// Render via the base text renderer first to preserve cell styling/classes
|
||||
// (readOnly, alignment, etc.), then override the displayed text.
|
||||
baseRenderer(instance, td, row, col, prop, value, cellProperties)
|
||||
|
||||
const num = Number(value)
|
||||
if (
|
||||
formatter &&
|
||||
value !== null &&
|
||||
value !== undefined &&
|
||||
value !== '' &&
|
||||
!isNaN(num)
|
||||
) {
|
||||
td.textContent = formatter.format(num)
|
||||
}
|
||||
|
||||
return td
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom renderer for HOT cell
|
||||
* Used to show error icon
|
||||
|
||||
@@ -16,11 +16,13 @@
|
||||
<clr-icon
|
||||
*ngIf="searchLibTreeInput.value.length < 1"
|
||||
shape="search"
|
||||
aria-hidden="true"
|
||||
></clr-icon>
|
||||
<clr-icon
|
||||
*ngIf="searchLibTreeInput.value.length > 0"
|
||||
(click)="librariesSearch = ''; libraryOnFilter()"
|
||||
shape="times"
|
||||
aria-label="Clear libraries search"
|
||||
></clr-icon>
|
||||
</div>
|
||||
</clr-tree-node>
|
||||
@@ -40,7 +42,7 @@
|
||||
"
|
||||
class="m-0 cursor-pointer"
|
||||
>
|
||||
<clr-icon shape="rack-server"></clr-icon>
|
||||
<clr-icon shape="rack-server" aria-hidden="true"></clr-icon>
|
||||
{{ library.LIBRARYREF }}
|
||||
</p>
|
||||
|
||||
@@ -60,6 +62,7 @@
|
||||
<clr-icon
|
||||
*ngIf="searchTreeInput.value.length < 1"
|
||||
shape="search"
|
||||
aria-hidden="true"
|
||||
></clr-icon>
|
||||
<clr-icon
|
||||
*ngIf="searchTreeInput.value.length > 0"
|
||||
@@ -69,6 +72,7 @@
|
||||
treeOnFilter(library, 'tables')
|
||||
"
|
||||
shape="times"
|
||||
aria-label="Clear tables search"
|
||||
></clr-icon>
|
||||
</div>
|
||||
</clr-tree-node>
|
||||
@@ -90,8 +94,16 @@
|
||||
[class.active]="libTabActive(library.LIBRARYREF, libTable)"
|
||||
>
|
||||
<ng-container [ngSwitch]="libTable.includes('-FC')">
|
||||
<clr-icon *ngSwitchCase="true" shape="bolt"></clr-icon>
|
||||
<clr-icon *ngSwitchCase="false" shape="table"></clr-icon>
|
||||
<clr-icon
|
||||
*ngSwitchCase="true"
|
||||
shape="bolt"
|
||||
aria-hidden="true"
|
||||
></clr-icon>
|
||||
<clr-icon
|
||||
*ngSwitchCase="false"
|
||||
shape="table"
|
||||
aria-hidden="true"
|
||||
></clr-icon>
|
||||
</ng-container>
|
||||
{{ libTable.replace('-FC', '') }}
|
||||
</button>
|
||||
@@ -114,7 +126,7 @@
|
||||
</clr-tree>
|
||||
</app-sidebar>
|
||||
|
||||
<div class="content-area">
|
||||
<main class="content-area">
|
||||
<div class="card-block">
|
||||
<div *ngIf="loading" class="spinner-wrapper-fullpage">
|
||||
<div class="loadingSpinner">
|
||||
@@ -144,4 +156,4 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
</p>
|
||||
</ng-container>
|
||||
|
||||
<p><strong>Protocol:</strong> {{ protocol }}</p>
|
||||
|
||||
<p>
|
||||
<strong>SYSSITE:</strong>
|
||||
<span
|
||||
|
||||
@@ -35,6 +35,10 @@ export class LicensingComponent implements OnInit {
|
||||
public activationKeyValue: string = ''
|
||||
|
||||
public applyingKeys: boolean = false
|
||||
public protocol: string =
|
||||
location.protocol === 'https:'
|
||||
? 'HTTPS - secure connection'
|
||||
: 'HTTP - insecure connection'
|
||||
|
||||
public syssite = this.appService.syssite
|
||||
public currentLicenceKey = this.licenceService.licenceKey
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import {
|
||||
DynamicCellValidation,
|
||||
DynamicExtendedCellValidation
|
||||
} from '../editor/models/dynamicExtendedCellValidation'
|
||||
|
||||
export interface CellValidationSource {
|
||||
col: number
|
||||
row: number
|
||||
strict: boolean
|
||||
values: any[]
|
||||
extended_values?: string[]
|
||||
values: DynamicCellValidation[]
|
||||
extended_values?: DynamicExtendedCellValidation[]
|
||||
hash: string
|
||||
count: number
|
||||
pending?: Promise<void>
|
||||
}
|
||||
|
||||
@@ -127,6 +127,7 @@
|
||||
class="no-table-selected pointer-events-none"
|
||||
>
|
||||
<clr-icon
|
||||
aria-hidden="true"
|
||||
shape="upload-cloud"
|
||||
size="40"
|
||||
class="is-info icon-dc-fill"
|
||||
@@ -197,6 +198,7 @@
|
||||
>
|
||||
<ng-container *ngIf="fileLoadingState !== FileLoadingState.parsed">
|
||||
<clr-icon
|
||||
aria-hidden="true"
|
||||
shape="process-on-vm"
|
||||
size="40"
|
||||
class="is-info icon-dc-fill"
|
||||
@@ -209,6 +211,7 @@
|
||||
|
||||
<ng-container *ngIf="fileLoadingState === FileLoadingState.parsed">
|
||||
<clr-icon
|
||||
aria-hidden="true"
|
||||
shape="warning-standard"
|
||||
size="40"
|
||||
class="is-info icon-dc-fill"
|
||||
@@ -372,6 +375,7 @@
|
||||
class="no-table-selected pointer-events-none"
|
||||
>
|
||||
<clr-icon
|
||||
aria-hidden="true"
|
||||
shape="warning-standard"
|
||||
size="40"
|
||||
class="is-info icon-dc-fill"
|
||||
|
||||
@@ -160,14 +160,16 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
||||
filters: true,
|
||||
stretchH: 'all',
|
||||
afterGetColHeader: baseAfterGetColHeader,
|
||||
modifyColWidth: this.maxWidthCheker
|
||||
modifyColWidth: this.maxWidthCheker,
|
||||
theme: 'ht-theme-classic'
|
||||
}
|
||||
|
||||
// Exclude data from settings for HOT v16 - it will be loaded manually
|
||||
const { data, ...settingsWithoutData } = this.hotUserDatasets
|
||||
this.hotUserDatasetsSettings = {
|
||||
...settingsWithoutData,
|
||||
licenseKey: this.hotTableLicenseKey
|
||||
licenseKey: this.hotTableLicenseKey,
|
||||
theme: 'ht-theme-classic'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { sanitiseForSas } from '../../shared/utils/sanitise'
|
||||
import { SasStoreService } from '../../services/sas-store.service'
|
||||
import {
|
||||
Component,
|
||||
@@ -136,7 +137,7 @@ export class ApproveDetailsComponent implements AfterViewInit, OnDestroy {
|
||||
|
||||
public async rejecting() {
|
||||
this.rejectLoading = true
|
||||
this.submitReason = this.submitReason.replace(/\n/g, '. ')
|
||||
this.submitReason = sanitiseForSas(this.submitReason.replace(/\n/g, '. '))
|
||||
|
||||
let rejParams = {
|
||||
STP_ACTION: 'REJECT_TABLE',
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { HelperService } from './helper.service'
|
||||
|
||||
describe('HelperService - convertArrayValues', () => {
|
||||
let service: HelperService
|
||||
|
||||
beforeEach(() => {
|
||||
// convertArrayValues does not touch any injected dependency
|
||||
service = new HelperService(null as any)
|
||||
})
|
||||
|
||||
it('returns an empty array for empty input', () => {
|
||||
expect(service.convertArrayValues([], 'string')).toEqual([])
|
||||
expect(service.convertArrayValues([], 'number')).toEqual([])
|
||||
})
|
||||
|
||||
it('coerces values to numbers for the numeric path', () => {
|
||||
expect(service.convertArrayValues(['1', '2', '3'], 'number')).toEqual([
|
||||
1, 2, 3
|
||||
])
|
||||
})
|
||||
|
||||
it('coerces values to strings for the string path', () => {
|
||||
expect(service.convertArrayValues([1, 2, 3], 'string')).toEqual([
|
||||
'1',
|
||||
'2',
|
||||
'3'
|
||||
])
|
||||
})
|
||||
|
||||
// Regression: a RAW_VALUE / dropdown source value that itself contains a
|
||||
// comma must stay a single item. Previously `array.toString().split(',')`
|
||||
// joined the array and re-split on every comma, shattering one value into
|
||||
// several. Same bug that hit display_value lists.
|
||||
it('keeps a single value containing commas intact (string path)', () => {
|
||||
expect(service.convertArrayValues(['Smith, John'], 'string')).toEqual([
|
||||
'Smith, John'
|
||||
])
|
||||
})
|
||||
|
||||
it('does not split multiple comma-containing values into individual items', () => {
|
||||
const source = ['Smith, John', 'Doe, Jane', 'Plain']
|
||||
|
||||
const result = service.convertArrayValues(source, 'string')
|
||||
|
||||
expect(result).toEqual(['Smith, John', 'Doe, Jane', 'Plain'])
|
||||
expect(result.length).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -280,7 +280,7 @@ export class HelperService {
|
||||
return array.map((value) => value * 1)
|
||||
}
|
||||
case 'string': {
|
||||
return array.toString().split(',')
|
||||
return array.map((value) => `${value}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,15 +170,15 @@ export class LicenceService {
|
||||
errString
|
||||
)}&force=true`
|
||||
this.licenceProblem.next(url)
|
||||
this.router.navigateByUrl(url)
|
||||
|
||||
setTimeout(() => {
|
||||
return this.applicationActivation(
|
||||
this.applicationActivation(
|
||||
this.freeTierLicenceData,
|
||||
variables,
|
||||
startup_site_id,
|
||||
true
|
||||
)
|
||||
this.router.navigateByUrl(url)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -155,13 +155,23 @@ export class SasStoreService {
|
||||
.adapterResponse
|
||||
}
|
||||
|
||||
private libsPromise: Promise<any> | null = null
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns All libraries
|
||||
*/
|
||||
public async viewLibs() {
|
||||
return (await this.sasService.request('public/viewlibs', null))
|
||||
.adapterResponse
|
||||
public viewLibs() {
|
||||
if (!this.libsPromise) {
|
||||
this.libsPromise = this.sasService
|
||||
.request('public/viewlibs', null)
|
||||
.then((res: any) => res.adapterResponse)
|
||||
.catch((err: any) => {
|
||||
this.libsPromise = null
|
||||
throw err
|
||||
})
|
||||
}
|
||||
return this.libsPromise
|
||||
}
|
||||
|
||||
public async refreshLibInfo(libref: string) {
|
||||
|
||||
@@ -641,6 +641,23 @@ export class SasService {
|
||||
this.sasjsAdapter.setDebugState(state)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the `&_debug=...` URL segment honoring the live adapter
|
||||
* config. Empty string when debug is off. `128` on the Viya WEB JES path
|
||||
* with `runAsTask` enabled, `131` otherwise.
|
||||
*/
|
||||
public getDebugUrlParam(): string {
|
||||
const config = this.sasjsAdapter.getSasjsConfig()
|
||||
if (!config.debug) return ''
|
||||
const value =
|
||||
config.serverType === ServerType.SasViya &&
|
||||
config.useComputeApi === null &&
|
||||
config.runAsTask === true
|
||||
? 128
|
||||
: 131
|
||||
return `&_debug=${value}`
|
||||
}
|
||||
|
||||
public getSasjsInstance() {
|
||||
return this.sasjsAdapter
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
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', TYPE: 'char', DDTYPE: 'CHAR' },
|
||||
{ NAME: 'SOME_NUM', TYPE: 'num', DDTYPE: 'NUMERIC' },
|
||||
{ NAME: 'SOME_TIME', TYPE: 'num', DDTYPE: 'TIME' },
|
||||
{ NAME: 'SOME_DATE', TYPE: 'num', DDTYPE: 'DATE' },
|
||||
{ NAME: 'SOME_DATETIME', TYPE: 'num', DDTYPE: 'DATETIME' }
|
||||
]
|
||||
|
||||
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('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']
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
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,266 @@
|
||||
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 kind = this.columnKind(varName, cols)
|
||||
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))
|
||||
.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(',')})`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SAS data-type kind of a column, from the loaded column specs. DDTYPE carries
|
||||
* TIME/DATE/DATETIME; checked DATETIME-before-DATE (substring).
|
||||
*/
|
||||
private columnKind(varName: string, cols: any[]): VaColumnKind {
|
||||
const col = (cols || []).find(
|
||||
(x: any) =>
|
||||
(x?.NAME ?? '').toString().toUpperCase() === varName.toUpperCase()
|
||||
)
|
||||
const ddtype = (col?.DDTYPE ?? '').toString().toUpperCase()
|
||||
if (ddtype.includes('DATETIME')) return 'datetime'
|
||||
if (ddtype.includes('DATE')) return 'date'
|
||||
if (ddtype.includes('TIME')) return 'time'
|
||||
if ((col?.TYPE ?? '') === 'num') return 'numeric'
|
||||
return 'char'
|
||||
}
|
||||
|
||||
/**
|
||||
* Infers a column's SAS kind from a VA value string (deferred load, no
|
||||
* metadata). VA's temporal strings are distinctive — `ddMMMyyyy[:HH:MM:SS]`
|
||||
* and `H:MM:SS` — so they're detected by shape; otherwise fall back to the VA
|
||||
* `dataType`.
|
||||
*/
|
||||
private inferKindFromValue(value: string, dataType?: string): VaColumnKind {
|
||||
if (/^\d{1,2}[A-Za-z]{3}\d{4}:\d{2}:\d{2}(:\d{2})?$/.test(value)) {
|
||||
return 'datetime'
|
||||
}
|
||||
if (/^\d{1,2}[A-Za-z]{3}\d{4}$/.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.
|
||||
*/
|
||||
private formatValueByKind(value: any, kind: VaColumnKind): 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': {
|
||||
const secs = this.timeToSeconds(s)
|
||||
return secs === null ? null : `${secs}`
|
||||
}
|
||||
case 'date': {
|
||||
const d = this.sasDateToJsDate(s)
|
||||
return d
|
||||
? `${this.helperService.convertJsDateToSasDate(d, 'days')}`
|
||||
: null
|
||||
}
|
||||
case 'datetime': {
|
||||
const d = this.sasDatetimeToJsDate(s)
|
||||
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
|
||||
}
|
||||
|
||||
/** SAS "ddMMMyyyy" (e.g. 01JUL1960) -> local JS Date, or null. */
|
||||
private sasDateToJsDate(value: string): Date | null {
|
||||
const m = /^(\d{1,2})([A-Za-z]{3})(\d{4})$/.exec(value)
|
||||
if (!m) return null
|
||||
const monIdx = VaFilterService.SAS_MONTHS.indexOf(m[2].toUpperCase())
|
||||
if (monIdx < 0) return null
|
||||
return new Date(Number(m[3]), monIdx, Number(m[1]))
|
||||
}
|
||||
|
||||
/** SAS "ddMMMyyyy:HH:MM[:SS]" (e.g. 01JAN1960:00:06:05) -> local JS Date, or null. */
|
||||
private sasDatetimeToJsDate(value: string): Date | null {
|
||||
const m =
|
||||
/^(\d{1,2})([A-Za-z]{3})(\d{4}):(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(value)
|
||||
if (!m) return null
|
||||
const monIdx = VaFilterService.SAS_MONTHS.indexOf(m[2].toUpperCase())
|
||||
if (monIdx < 0) return null
|
||||
return new Date(
|
||||
Number(m[3]),
|
||||
monIdx,
|
||||
Number(m[1]),
|
||||
Number(m[4]),
|
||||
Number(m[5]),
|
||||
Number(m[6] || 0)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<clr-modal
|
||||
[clrModalOpen]="open"
|
||||
[clrModalClosable]="false"
|
||||
[clrModalStaticBackdrop]="true"
|
||||
[clrModalSize]="'sm'"
|
||||
>
|
||||
<h3 class="modal-title">Validating cells</h3>
|
||||
<div class="modal-body bulk-validation-body">
|
||||
<div class="bulk-validation-row">
|
||||
<clr-spinner clrSmall></clr-spinner>
|
||||
<span class="bulk-validation-text">
|
||||
Validating {{ done }} / {{ total }}…
|
||||
</span>
|
||||
</div>
|
||||
<clr-progress-bar
|
||||
class="bulk-validation-progress"
|
||||
[clrValue]="done"
|
||||
[clrMax]="total"
|
||||
></clr-progress-bar>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline" (click)="onCancel()">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</clr-modal>
|
||||
@@ -0,0 +1,19 @@
|
||||
.bulk-validation-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
||||
.bulk-validation-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bulk-validation-text {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.bulk-validation-progress {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core'
|
||||
|
||||
@Component({
|
||||
selector: 'app-bulk-validation-modal',
|
||||
templateUrl: './bulk-validation-modal.component.html',
|
||||
styleUrls: ['./bulk-validation-modal.component.scss'],
|
||||
standalone: false
|
||||
})
|
||||
export class BulkValidationModalComponent {
|
||||
@Input() open = false
|
||||
@Input() done = 0
|
||||
@Input() total = 0
|
||||
|
||||
@Output() cancel = new EventEmitter<void>()
|
||||
|
||||
onCancel() {
|
||||
this.cancel.emit()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<clr-modal
|
||||
[clrModalOpen]="open"
|
||||
(clrModalOpenChange)="onClrModalOpenChange($event)"
|
||||
[clrModalSize]="'md'"
|
||||
>
|
||||
<h3 class="modal-title">{{ title }}</h3>
|
||||
<div class="modal-body">
|
||||
<div>{{ message }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline" (click)="onCancel()">
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" (click)="onConfirm()">
|
||||
{{ confirmText }}
|
||||
</button>
|
||||
</div>
|
||||
</clr-modal>
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core'
|
||||
|
||||
@Component({
|
||||
selector: 'app-confirm-modal',
|
||||
templateUrl: './confirm-modal.component.html',
|
||||
standalone: false
|
||||
})
|
||||
export class ConfirmModalComponent {
|
||||
@Input() open = false
|
||||
@Input() title = 'Confirm'
|
||||
@Input() message = ''
|
||||
@Input() confirmText = 'Yes'
|
||||
@Input() cancelText = 'No'
|
||||
|
||||
@Output() result = new EventEmitter<boolean>()
|
||||
|
||||
onConfirm() {
|
||||
this.result.emit(true)
|
||||
}
|
||||
|
||||
onCancel() {
|
||||
this.result.emit(false)
|
||||
}
|
||||
|
||||
onClrModalOpenChange(value: boolean) {
|
||||
// Close triggered by X / outside / Esc — treat as cancel. Only emit
|
||||
// when modal was actually open (avoid double-emit when parent closes
|
||||
// us via [open]=false in response to the Yes/No button).
|
||||
if (!value && this.open) this.result.emit(false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import Handsontable from 'handsontable'
|
||||
import { datetimeValidator } from '../validations/hot-custom-validators'
|
||||
|
||||
/**
|
||||
* Cell types for the date/time/datetime columns after the HOT 17 migration.
|
||||
*
|
||||
* All three DISPLAY the raw stored ISO string verbatim — `YYYY-MM-DD`,
|
||||
* `HH:mm:ss`, `YYYY-MM-DD HH:mm:ss` — via an identity `valueFormatter`, so the
|
||||
* cell text is stable and locale-independent. The native picker editors still
|
||||
* follow the OS regional format while open; only the painted cell is ISO.
|
||||
* The stored/submitted value is unchanged.
|
||||
*
|
||||
* `intl-date` and `intl-time` are HOT built-ins; we re-register them by spreading
|
||||
* the built-in definition (keeping its native picker editor + ISO validator) and
|
||||
* swapping only the `valueFormatter` for the identity one. `intl-datetime` has no
|
||||
* built-in — it pairs a native `datetime-local` picker with the identity
|
||||
* formatter. valueFormatter is applied render-only (tableView), so copy/paste and
|
||||
* the stored value are untouched.
|
||||
*/
|
||||
|
||||
/** Identity formatter — display the stored ISO string as-is (no localization). */
|
||||
const isoPassthrough = (value: any): any => value
|
||||
|
||||
const SPACE_ISO = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}(?::\d{2})?)$/
|
||||
|
||||
const BaseTextEditor = Handsontable.editors.getEditor('text') as any
|
||||
const baseTextRenderer = Handsontable.renderers.getRenderer('text')
|
||||
|
||||
class IntlDatetimeEditor extends BaseTextEditor {
|
||||
createElements() {
|
||||
super.createElements('input')
|
||||
this.TEXTAREA.setAttribute('type', 'datetime-local')
|
||||
this.TEXTAREA.setAttribute('step', '1') // allow seconds
|
||||
}
|
||||
|
||||
// Stored `YYYY-MM-DD HH:mm:ss` → native input `YYYY-MM-DDTHH:mm:ss`.
|
||||
setValue(value: any) {
|
||||
const match = typeof value === 'string' ? value.match(SPACE_ISO) : null
|
||||
super.setValue(match ? `${match[1]}T${match[2]}` : value)
|
||||
}
|
||||
|
||||
// Native input `YYYY-MM-DDTHH:mm[:ss]` → stored `YYYY-MM-DD HH:mm:ss`.
|
||||
getValue() {
|
||||
const raw: string = super.getValue()
|
||||
if (typeof raw !== 'string' || raw.indexOf('T') === -1) return raw
|
||||
const [date, time] = raw.split('T')
|
||||
const withSeconds = time.length === 5 ? `${time}:00` : time // pad HH:mm
|
||||
return `${date} ${withSeconds}`
|
||||
}
|
||||
|
||||
open() {
|
||||
super.open()
|
||||
try {
|
||||
this.TEXTAREA.showPicker()
|
||||
} catch {
|
||||
/* showPicker requires a user gesture; ignore when called programmatically */
|
||||
}
|
||||
}
|
||||
|
||||
// The base text editor's focus() calls select() + setSelectionRange to place
|
||||
// the caret, but a native `datetime-local` input does not support text
|
||||
// selection and throws `InvalidStateError`. Just focus the input.
|
||||
focus() {
|
||||
this.TEXTAREA.focus()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-registers a built-in HOT cell type (`intl-date` / `intl-time`) with only its
|
||||
* `valueFormatter` swapped for the ISO passthrough — the native picker editor and
|
||||
* ISO validator are reused from the built-in definition. Idempotent: a second
|
||||
* call reads back our already-overridden type, which still carries the original
|
||||
* editor/validator.
|
||||
*/
|
||||
const overrideWithPassthrough = (type: string) => {
|
||||
const builtin = Handsontable.cellTypes.getCellType(type) as any
|
||||
Handsontable.cellTypes.registerCellType(type, {
|
||||
...builtin,
|
||||
valueFormatter: isoPassthrough
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the Intl date/time/datetime cell types. Idempotent — safe to call
|
||||
* per DcValidator construction. All three display the stored ISO value verbatim.
|
||||
*/
|
||||
export const registerIntlCellTypes = () => {
|
||||
overrideWithPassthrough('intl-date')
|
||||
overrideWithPassthrough('intl-time')
|
||||
|
||||
Handsontable.cellTypes.registerCellType('intl-datetime', {
|
||||
// IntlDatetimeEditor extends the (loosely-typed) text editor returned by
|
||||
// getEditor('text'); cast to satisfy registerCellType's editor signature.
|
||||
editor: IntlDatetimeEditor as any,
|
||||
renderer: baseTextRenderer,
|
||||
validator: datetimeValidator,
|
||||
valueFormatter: isoPassthrough
|
||||
} as any)
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import Handsontable from 'handsontable'
|
||||
import { CellProperties } from 'handsontable/settings'
|
||||
import {
|
||||
autocompleteValidator,
|
||||
dateValidator,
|
||||
timeValidator
|
||||
intlDateValidator,
|
||||
intlTimeValidator
|
||||
} from 'handsontable/validators'
|
||||
import { $DataFormats } from 'src/app/models/sas/editors-getdata.model'
|
||||
import { DQData, SASParam } from '../../models/TableData'
|
||||
@@ -19,9 +19,15 @@ import { getNotNullDefault } from './utils/getNotNullDefault'
|
||||
import { mergeColsRules } from './utils/mergeColsRules'
|
||||
import { parseColType } from './utils/parseColType'
|
||||
import { dqValidate } from './validations/dq-validation'
|
||||
import { specialMissingNumericValidator } from './validations/hot-custom-validators'
|
||||
import {
|
||||
datetimeValidator,
|
||||
specialMissingNumericValidator
|
||||
} from './validations/hot-custom-validators'
|
||||
import { applyNumericFormats } from './utils/applyNumericFormats'
|
||||
import { mapIntlCellTypes } from './utils/mapIntlCellTypes'
|
||||
import { CustomAutocompleteEditor } from './editors/numericAutocomplete'
|
||||
import { registerIntlCellTypes } from './cellTypes/intlCellTypes'
|
||||
import { makeNumberFormatRenderer } from '../../editor/utils/renderers.utils'
|
||||
|
||||
export class DcValidator {
|
||||
private rules: DcValidation[] = []
|
||||
@@ -47,6 +53,7 @@ export class DcValidator {
|
||||
this.rules = parseColType(sasparams.COLTYPE)
|
||||
this.rules = mergeColsRules(cols, this.rules, $dataFormats)
|
||||
this.rules = applyNumericFormats(this.rules)
|
||||
this.rules = mapIntlCellTypes(this.rules)
|
||||
this.dqrules = dqRules
|
||||
this.dqdata = dqData
|
||||
this.primaryKeys = sasparams.PK.split(' ')
|
||||
@@ -60,6 +67,7 @@ export class DcValidator {
|
||||
'autocomplete.custom',
|
||||
CustomAutocompleteEditor
|
||||
)
|
||||
registerIntlCellTypes()
|
||||
}
|
||||
|
||||
getRules(): DcValidation[] {
|
||||
@@ -147,6 +155,25 @@ export class DcValidator {
|
||||
return getNotNullDefault(col, this.dqrules, colRule?.type)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the num_digits for a ROUND rule on the given column, used to
|
||||
* round edited/pasted values Excel-style. Returns undefined if no ROUND
|
||||
* rule exists or its RULE_VALUE is not an integer.
|
||||
*
|
||||
* @param col column name
|
||||
*/
|
||||
getRoundDigits(col: string): number | undefined {
|
||||
const roundRule = this.dqrules.find(
|
||||
(rule: DQRule) => rule.BASE_COL === col && rule.RULE_TYPE === 'ROUND'
|
||||
)
|
||||
|
||||
if (!roundRule) return undefined
|
||||
|
||||
const digits = parseInt(roundRule.RULE_VALUE, 10)
|
||||
|
||||
return isNaN(digits) ? undefined : digits
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves dropdown source for given dc validation rule
|
||||
* The values comes from MPE_SELECTBOX table
|
||||
@@ -238,7 +265,6 @@ export class DcValidator {
|
||||
const cellProperties: CellProperties = {
|
||||
...columnRules,
|
||||
validator: undefined,
|
||||
correctFormat: false,
|
||||
row: 0,
|
||||
col: 0,
|
||||
instance: new Handsontable(document.createElement('div'), {}),
|
||||
@@ -284,10 +310,18 @@ export class DcValidator {
|
||||
)
|
||||
|
||||
if (source.length > 0) {
|
||||
// For SAS num cols keep type='numeric' so HOT's numericEditor /
|
||||
// numericRenderer + numbro coercion stay alive — same per-column
|
||||
// model as the per-cell pattern in
|
||||
// editor.component.ts:reSetCellValidationValues().
|
||||
this.rules[i].source = source
|
||||
this.rules[i].type = 'autocomplete'
|
||||
this.rules[i].editor = 'autocomplete.custom'
|
||||
this.rules[i].renderer = 'autocomplete'
|
||||
this.rules[i].filter = false
|
||||
|
||||
if (this.rules[i].sasType !== 'num') {
|
||||
this.rules[i].type = 'autocomplete'
|
||||
}
|
||||
}
|
||||
|
||||
if (this.hasDqRules(ruleColName, ['SOFTSELECT'])) {
|
||||
@@ -309,11 +343,31 @@ export class DcValidator {
|
||||
if (this.hasDqRules(ruleColName, ['NOTNULL'])) {
|
||||
this.rules[i].allowEmpty = false
|
||||
}
|
||||
}
|
||||
|
||||
// Correct format comes as STRING from SAS. That could be also fixed on SAS side.
|
||||
if ((this.rules[i].correctFormat as any) === 'true') {
|
||||
this.rules[i].correctFormat = true
|
||||
// READONLY: render column read-only (default value handled via getColumnDefault on add row)
|
||||
if (this.hasDqRules(ruleColName, ['READONLY'])) {
|
||||
this.rules[i].readOnly = true
|
||||
}
|
||||
|
||||
// HIDDEN: hide column in HOT but keep its data (still submitted via hot.getData())
|
||||
if (this.hasDqRules(ruleColName, ['HIDDEN'])) {
|
||||
this.hiddenColumns.push(i)
|
||||
}
|
||||
|
||||
// NUMBER_FORMAT: display-only Intl.NumberFormat renderer.
|
||||
// Set last so it overrides a dropdown's 'autocomplete' renderer (last-wins).
|
||||
if (this.hasDqRules(ruleColName, ['NUMBER_FORMAT'])) {
|
||||
const fmtRule = this.getDqDetails(ruleColName).find(
|
||||
(rule: DQRule) => rule.RULE_TYPE === 'NUMBER_FORMAT'
|
||||
)
|
||||
this.rules[i].renderer = makeNumberFormatRenderer(fmtRule?.RULE_VALUE)
|
||||
// Clear numericFormat (set by applyNumericFormats on every numeric
|
||||
// column). On a numeric cell HOT's built-in numeric renderer would
|
||||
// re-format via numericFormat, overriding our custom NUMBER_FORMAT
|
||||
// Intl renderer and dropping the currency symbol. The numeric
|
||||
// editor/validator stay intact via `type`.
|
||||
this.rules[i].numericFormat = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const self = this
|
||||
@@ -455,10 +509,12 @@ export class DcValidator {
|
||||
return autocompleteValidator
|
||||
case 'numeric':
|
||||
return specialMissingNumericValidator
|
||||
case 'date':
|
||||
return dateValidator
|
||||
case 'time':
|
||||
return timeValidator
|
||||
case 'intl-date':
|
||||
return intlDateValidator
|
||||
case 'intl-time':
|
||||
return intlTimeValidator
|
||||
case 'intl-datetime':
|
||||
return datetimeValidator
|
||||
default:
|
||||
return (value: any, callback: any) => (callback ? callback(true) : null)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ export interface DcColumnSettings {
|
||||
valid?: boolean
|
||||
desc?: string
|
||||
clsRule?: string
|
||||
// SAS-side column type from $dataFormats (e.g. 'num', 'char') — distinct
|
||||
// from Handsontable's `type` which drives renderer/editor selection
|
||||
sasType?: string
|
||||
}
|
||||
|
||||
export interface DcValidation extends HotColumnSettings, DcColumnSettings {}
|
||||
|
||||
@@ -14,3 +14,7 @@ export type DQRuleTypes =
|
||||
| 'CASE'
|
||||
| 'MINVAL'
|
||||
| 'MAXVAL'
|
||||
| 'READONLY'
|
||||
| 'HIDDEN'
|
||||
| 'ROUND'
|
||||
| 'NUMBER_FORMAT'
|
||||
|
||||
@@ -34,10 +34,22 @@ describe('DC Validator', () => {
|
||||
// Check allowEmpty - it is notnull rule so allowEmpty should be false
|
||||
expect(someNumRule?.allowEmpty).toBeFalse()
|
||||
|
||||
// Get col with notnull validation
|
||||
// SAS sends semantic date/time/datetime column types; the frontend maps them
|
||||
// to the HOT 17 Intl cell-type names (see mapIntlCellTypes). The cell display
|
||||
// is the raw stored ISO string (no locale/format meta) — see
|
||||
// registerIntlCellTypes. The deprecated correctFormat + moment format strings
|
||||
// are no longer sent in the payload.
|
||||
const someDateRule = dcValidator.getRule('SOME_DATE')
|
||||
// Check correctFormat - it comes from SAS - should be true
|
||||
expect(someDateRule?.correctFormat).toBeTrue()
|
||||
expect(someDateRule?.correctFormat).toBeUndefined()
|
||||
expect(someDateRule?.type).toBe('intl-date')
|
||||
|
||||
const someDatetimeRule = dcValidator.getRule('SOME_DATETIME')
|
||||
expect(someDatetimeRule?.type).toBe('intl-datetime')
|
||||
expect(someDatetimeRule?.correctFormat).toBeUndefined()
|
||||
|
||||
const someTimeRule = dcValidator.getRule('SOME_TIME')
|
||||
expect(someTimeRule?.type).toBe('intl-time')
|
||||
expect(someTimeRule?.correctFormat).toBeUndefined()
|
||||
|
||||
// Get col with dropdown, check if merging sas data is correct
|
||||
const someDropdownRule = dcValidator.getRule('SOME_DROPDOWN')
|
||||
@@ -235,6 +247,37 @@ describe('DC Validator', () => {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should apply READONLY, HIDDEN, NUMBER_FORMAT and ROUND rules', () => {
|
||||
const dcValidator: DcValidator = new DcValidator(
|
||||
example_sasparams,
|
||||
example_dataformats,
|
||||
example_cols,
|
||||
example_dqRules,
|
||||
example_dqData
|
||||
)
|
||||
|
||||
// READONLY -> column rendered read-only
|
||||
expect(dcValidator.getRule('SOME_BESTNUM')?.readOnly).toBeTrue()
|
||||
|
||||
// NUMBER_FORMAT -> a function renderer is assigned and numbro numericFormat
|
||||
// is cleared so it can't override the Intl currency/percent rendering
|
||||
expect(typeof dcValidator.getRule('SOME_BESTNUM')?.renderer).toEqual(
|
||||
'function'
|
||||
)
|
||||
expect(dcValidator.getRule('SOME_BESTNUM')?.numericFormat).toBeUndefined()
|
||||
|
||||
// HIDDEN -> column index added to hidden columns
|
||||
const rules = dcValidator.getRules()
|
||||
const hiddenIndex = rules.findIndex(
|
||||
(rule) => rule.data === 'PRIMARY_KEY_FIELD'
|
||||
)
|
||||
expect(dcValidator.getHiddenColumns()).toContain(hiddenIndex)
|
||||
|
||||
// ROUND -> num_digits returned for the column, undefined otherwise
|
||||
expect(dcValidator.getRoundDigits('SOME_SHORTNUM')).toEqual(2)
|
||||
expect(dcValidator.getRoundDigits('SOME_NUM')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
const example_dqData = [
|
||||
@@ -313,6 +356,30 @@ const example_dqRules: any = [
|
||||
RULE_TYPE: 'CASE',
|
||||
RULE_VALUE: 'LOWCASE',
|
||||
X: 0
|
||||
},
|
||||
{
|
||||
BASE_COL: 'SOME_BESTNUM',
|
||||
RULE_TYPE: 'READONLY',
|
||||
RULE_VALUE: '7',
|
||||
X: 0
|
||||
},
|
||||
{
|
||||
BASE_COL: 'SOME_BESTNUM',
|
||||
RULE_TYPE: 'NUMBER_FORMAT',
|
||||
RULE_VALUE: '{"minimumFractionDigits":2}',
|
||||
X: 0
|
||||
},
|
||||
{
|
||||
BASE_COL: 'PRIMARY_KEY_FIELD',
|
||||
RULE_TYPE: 'HIDDEN',
|
||||
RULE_VALUE: '99',
|
||||
X: 0
|
||||
},
|
||||
{
|
||||
BASE_COL: 'SOME_SHORTNUM',
|
||||
RULE_TYPE: 'ROUND',
|
||||
RULE_VALUE: '2',
|
||||
X: 0
|
||||
}
|
||||
]
|
||||
|
||||
@@ -379,21 +446,15 @@ const example_COLTYPE = `
|
||||
},
|
||||
{
|
||||
"data":"SOME_DATE",
|
||||
"type":"date",
|
||||
"dateFormat":"YYYY-MM-DD",
|
||||
"correctFormat":"true"
|
||||
"type":"date"
|
||||
},
|
||||
{
|
||||
"data":"SOME_DATETIME",
|
||||
"type":"date",
|
||||
"dateFormat":"YYYY-MM-DD HH:mm:ss.SSS",
|
||||
"correctFormat":"true"
|
||||
"type":"datetime"
|
||||
},
|
||||
{
|
||||
"data":"SOME_TIME",
|
||||
"type":"time",
|
||||
"timeFormat":"HH:mm:ss.SSS",
|
||||
"correctFormat":"true"
|
||||
"type":"time"
|
||||
},
|
||||
{
|
||||
"data":"SOME_SHORTNUM",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { excelRound } from '../utils/excelRound'
|
||||
|
||||
describe('DC Validator - excelRound', () => {
|
||||
it('should round to the given number of decimal places', () => {
|
||||
expect(excelRound(1.23456, 2)).toEqual(1.23)
|
||||
expect(excelRound(1.236, 2)).toEqual(1.24)
|
||||
expect(excelRound(2.5, 0)).toEqual(3)
|
||||
})
|
||||
|
||||
it('should round half away from zero for negative values', () => {
|
||||
expect(excelRound(-0.5, 0)).toEqual(-1)
|
||||
expect(excelRound(-2.5, 0)).toEqual(-3)
|
||||
expect(excelRound(-1.23456, 2)).toEqual(-1.23)
|
||||
})
|
||||
|
||||
it('should support negative digits (round to tens/hundreds)', () => {
|
||||
expect(excelRound(25, -1)).toEqual(30)
|
||||
expect(excelRound(24, -1)).toEqual(20)
|
||||
expect(excelRound(150, -2)).toEqual(200)
|
||||
})
|
||||
|
||||
// Examples from Microsoft's ROUND documentation:
|
||||
// https://support.microsoft.com/en-us/office/round-function-c018c5d8-40fb-4053-90b1-b3e7f61a213c
|
||||
it('should match the Microsoft ROUND examples', () => {
|
||||
expect(excelRound(2.15, 1)).toEqual(2.2)
|
||||
expect(excelRound(2.149, 1)).toEqual(2.1)
|
||||
expect(excelRound(-1.475, 2)).toEqual(-1.48)
|
||||
expect(excelRound(21.5, -1)).toEqual(20)
|
||||
expect(excelRound(626.3, -3)).toEqual(1000)
|
||||
expect(excelRound(1.98, -1)).toEqual(0)
|
||||
expect(excelRound(-50.55, -2)).toEqual(-100)
|
||||
})
|
||||
|
||||
it('should return the original value when not finite', () => {
|
||||
expect(excelRound(NaN, 2)).toBeNaN()
|
||||
expect(excelRound(Infinity, 2)).toEqual(Infinity)
|
||||
expect(excelRound(5, NaN)).toEqual(5)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { DQRule } from '../models/dq-rules.model'
|
||||
import { getColumnDefault } from '../utils/getColumnDefault'
|
||||
|
||||
describe('DC Validator - getColumnDefault', () => {
|
||||
const dqRules: DQRule[] = [
|
||||
{ BASE_COL: 'NOTNULL_COL', RULE_TYPE: 'NOTNULL', RULE_VALUE: 'nn', X: 1 },
|
||||
{ BASE_COL: 'READONLY_COL', RULE_TYPE: 'READONLY', RULE_VALUE: 'ro', X: 1 },
|
||||
{ BASE_COL: 'HIDDEN_COL', RULE_TYPE: 'HIDDEN', RULE_VALUE: 'hd', X: 1 },
|
||||
{ BASE_COL: 'NUM_COL', RULE_TYPE: 'READONLY', RULE_VALUE: '42', X: 1 },
|
||||
{ BASE_COL: 'EMPTY_COL', RULE_TYPE: 'HIDDEN', RULE_VALUE: ' ', X: 1 },
|
||||
{ BASE_COL: 'OTHER_COL', RULE_TYPE: 'HARDSELECT', RULE_VALUE: 'x', X: 1 }
|
||||
]
|
||||
|
||||
it('should return defaults for NOTNULL, READONLY and HIDDEN rules', () => {
|
||||
expect(getColumnDefault('NOTNULL_COL', dqRules, 'text')).toEqual('nn')
|
||||
expect(getColumnDefault('READONLY_COL', dqRules, 'text')).toEqual('ro')
|
||||
expect(getColumnDefault('HIDDEN_COL', dqRules, 'text')).toEqual('hd')
|
||||
})
|
||||
|
||||
it('should coerce to number for numeric columns', () => {
|
||||
expect(getColumnDefault('NUM_COL', dqRules, 'numeric')).toEqual(42)
|
||||
})
|
||||
|
||||
it('should return string for numeric columns when value is non-numeric', () => {
|
||||
expect(getColumnDefault('READONLY_COL', dqRules, 'numeric')).toEqual('ro')
|
||||
})
|
||||
|
||||
it('should ignore empty RULE_VALUE', () => {
|
||||
expect(getColumnDefault('EMPTY_COL', dqRules, 'text')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined for rules that do not provide defaults', () => {
|
||||
expect(getColumnDefault('OTHER_COL', dqRules, 'text')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined for non-existent columns and empty rules', () => {
|
||||
expect(getColumnDefault('MISSING', dqRules, 'text')).toBeUndefined()
|
||||
expect(getColumnDefault('NOTNULL_COL', [], 'text')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -38,11 +38,52 @@ describe('DC Validator - merge spec rules', () => {
|
||||
data: 'test_col',
|
||||
desc: 'test_desc',
|
||||
clsRule: 'cls_rule',
|
||||
length: 8
|
||||
length: 8,
|
||||
sasType: 'test_type'
|
||||
}
|
||||
]
|
||||
|
||||
expect(mergeColsRules(cols, rules, $dataFormats)).toEqual(expected)
|
||||
expect(cols[0].TYPE).toEqual('test_type')
|
||||
})
|
||||
|
||||
it('should populate sasType for num and char cols', () => {
|
||||
const rules: DcValidation[] = [{ data: 'num_col' }, { data: 'char_col' }]
|
||||
const cols: Col[] = [
|
||||
{
|
||||
NAME: 'num_col',
|
||||
MEMLABEL: '',
|
||||
DESC: '',
|
||||
LONGDESC: '',
|
||||
TYPE: '',
|
||||
CLS_RULE: '',
|
||||
VARNUM: 0,
|
||||
LABEL: '',
|
||||
FMTNAME: '',
|
||||
DDTYPE: ''
|
||||
},
|
||||
{
|
||||
NAME: 'char_col',
|
||||
MEMLABEL: '',
|
||||
DESC: '',
|
||||
LONGDESC: '',
|
||||
TYPE: '',
|
||||
CLS_RULE: '',
|
||||
VARNUM: 0,
|
||||
LABEL: '',
|
||||
FMTNAME: '',
|
||||
DDTYPE: ''
|
||||
}
|
||||
]
|
||||
const $dataFormats: any = {
|
||||
vars: {
|
||||
num_col: { format: 'best.', label: '', length: '8', type: 'num' },
|
||||
char_col: { format: '$32.', label: '', length: '32', type: 'char' }
|
||||
}
|
||||
}
|
||||
|
||||
const merged = mergeColsRules(cols, rules, $dataFormats)
|
||||
expect(merged.find((r) => r.data === 'num_col')?.sasType).toEqual('num')
|
||||
expect(merged.find((r) => r.data === 'char_col')?.sasType).toEqual('char')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,25 +1,22 @@
|
||||
import { DcValidation } from '../models/dc-validation.model'
|
||||
import * as languages from 'numbro/dist/languages.min'
|
||||
/**
|
||||
* Applying the numeric formats based on the browser locale/language
|
||||
* So that correct decimal separators are applied.
|
||||
* For example european format (thousand dot, decimal comma): 1.000,00
|
||||
*
|
||||
* Uses Intl.NumberFormat options (HOT 17+) instead of the deprecated numbro
|
||||
* `pattern`/`culture`. `maximumFractionDigits: 20` preserves all natural
|
||||
* decimals (Intl's default of 3 would round); `locale` replaces `culture`.
|
||||
*
|
||||
* @param rules Cell Validation rules to be updated
|
||||
* Those rules are passed in the `columns` property Of handsontable settings.
|
||||
*/
|
||||
export const applyNumericFormats = (rules: DcValidation[]): DcValidation[] => {
|
||||
const lang = languages[window.navigator.language]
|
||||
|
||||
if (!lang) return rules
|
||||
|
||||
for (let rule of rules) {
|
||||
if (rule.type === 'numeric')
|
||||
rule.numericFormat = {
|
||||
pattern: '0,0',
|
||||
culture: window.navigator.language // use this for EUR (German),
|
||||
// more cultures available on http://numbrojs.com/languages.html
|
||||
}
|
||||
if (rule.type === 'numeric') {
|
||||
rule.numericFormat = { useGrouping: true, maximumFractionDigits: 20 }
|
||||
rule.locale = window.navigator.language
|
||||
}
|
||||
}
|
||||
|
||||
return rules
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Rounds a number like Excel's ROUND(number, num_digits):
|
||||
* - rounds half away from zero (so ROUND(-0.5) === -1, ROUND(2.5) === 3)
|
||||
* - supports negative `digits` (e.g. -1 rounds to the nearest ten)
|
||||
*
|
||||
* @param value number to round
|
||||
* @param digits number of decimal places (may be negative)
|
||||
* @returns the rounded number, or the original value if it is not finite
|
||||
*/
|
||||
export function excelRound(value: number, digits: number): number {
|
||||
if (!isFinite(value) || !isFinite(digits)) return value
|
||||
|
||||
const factor = Math.pow(10, digits)
|
||||
|
||||
return (Math.sign(value) * Math.round(Math.abs(value) * factor)) / factor
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { DQRule, DQRuleTypes } from '../models/dq-rules.model'
|
||||
|
||||
/**
|
||||
* Rule types whose RULE_VALUE supplies the default value inserted into a cell
|
||||
* when a new row is added. Checked in priority order.
|
||||
*/
|
||||
const DEFAULT_VALUE_RULE_TYPES: DQRuleTypes[] = [
|
||||
'NOTNULL',
|
||||
'READONLY',
|
||||
'HIDDEN'
|
||||
]
|
||||
|
||||
/**
|
||||
* Returns the default value for a column from DQ rules, used to pre-fill cells
|
||||
* when a new row is added. Looks for the first non-empty RULE_VALUE among
|
||||
* NOTNULL, READONLY and HIDDEN rules. Converts to number for numeric columns.
|
||||
*
|
||||
* @param colName column name to look up
|
||||
* @param dqRules array of DQ rules
|
||||
* @param colType column type (e.g., 'numeric', 'text')
|
||||
* @returns default value (string or number) if a default-providing rule exists
|
||||
* with a non-empty value, otherwise undefined
|
||||
*/
|
||||
export function getColumnDefault(
|
||||
colName: string,
|
||||
dqRules: DQRule[],
|
||||
colType?: string
|
||||
): string | number | undefined {
|
||||
const rule = dqRules.find(
|
||||
(rule: DQRule) =>
|
||||
rule.BASE_COL === colName &&
|
||||
DEFAULT_VALUE_RULE_TYPES.includes(rule.RULE_TYPE) &&
|
||||
rule.RULE_VALUE != null &&
|
||||
rule.RULE_VALUE.trim().length > 0
|
||||
)
|
||||
|
||||
if (!rule) return undefined
|
||||
|
||||
if (colType === 'numeric' && !isNaN(Number(rule.RULE_VALUE))) {
|
||||
return Number(rule.RULE_VALUE)
|
||||
}
|
||||
|
||||
return rule.RULE_VALUE
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DcValidation } from '../models/dc-validation.model'
|
||||
import { DQRule } from '../models/dq-rules.model'
|
||||
import { getNotNullDefault } from './getNotNullDefault'
|
||||
import { getColumnDefault } from './getColumnDefault'
|
||||
|
||||
const schemaTypeMap: { [key: string]: any } = {
|
||||
numeric: '',
|
||||
@@ -9,16 +9,16 @@ const schemaTypeMap: { [key: string]: any } = {
|
||||
|
||||
/**
|
||||
* Schema defines the default values for given types. For example when new row is added.
|
||||
* Priority: NOTNULL RULE_VALUE > autocomplete first option > type default
|
||||
* Priority: NOTNULL/READONLY/HIDDEN RULE_VALUE > autocomplete first option > type default
|
||||
*/
|
||||
export function getHotDataSchema(
|
||||
type: string | undefined,
|
||||
cellValidation?: DcValidation,
|
||||
dqRules?: DQRule[]
|
||||
): any {
|
||||
// Check for NOTNULL default first
|
||||
// Check for a rule-supplied default (NOTNULL/READONLY/HIDDEN) first
|
||||
if (dqRules && cellValidation?.data) {
|
||||
const defaultValue = getNotNullDefault(cellValidation.data, dqRules, type)
|
||||
const defaultValue = getColumnDefault(cellValidation.data, dqRules, type)
|
||||
if (defaultValue !== undefined) {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { DcValidation } from '../models/dc-validation.model'
|
||||
|
||||
/**
|
||||
* Maps SAS' semantic date/time/datetime column types to the HOT 17 Intl cell
|
||||
* type names. SAS sends a HOT-agnostic flag (`date`/`time`/`datetime`); the
|
||||
* frontend owns the HOT-specific cell-type names:
|
||||
*
|
||||
* - `date` → `intl-date` (native date picker)
|
||||
* - `time` → `intl-time` (native time picker)
|
||||
* - `datetime` → `intl-datetime` (native datetime-local picker)
|
||||
*
|
||||
* The cell display is the raw stored ISO string (locale-independent) — see
|
||||
* registerIntlCellTypes, where these types' `valueFormatter` is the identity
|
||||
* passthrough. No locale/format meta is attached; it is unused for display, and
|
||||
* the validators are ISO-based and locale-agnostic. Runs in the DcValidator
|
||||
* constructor, before the rules reach Handsontable.
|
||||
*
|
||||
* @param rules Cell validation rules passed to Handsontable's `columns`.
|
||||
*/
|
||||
const INTL_TYPE_MAP: Record<string, string> = {
|
||||
date: 'intl-date',
|
||||
time: 'intl-time',
|
||||
datetime: 'intl-datetime'
|
||||
}
|
||||
|
||||
export const mapIntlCellTypes = (rules: DcValidation[]): DcValidation[] => {
|
||||
for (const rule of rules) {
|
||||
const mapped = rule.type ? INTL_TYPE_MAP[rule.type as string] : undefined
|
||||
if (mapped) rule.type = mapped
|
||||
}
|
||||
|
||||
return rules
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export const mergeColsRules = (
|
||||
if (rule && col.DESC) rule.desc = col.DESC
|
||||
if (rule && colFormats.length) rule.length = parseInt(colFormats.length)
|
||||
if (rule && col.CLS_RULE) rule.clsRule = col.CLS_RULE
|
||||
if (rule && colFormats?.type) rule.sasType = colFormats.type
|
||||
}
|
||||
|
||||
return rules
|
||||
|
||||
@@ -22,3 +22,26 @@ export function specialMissingNumericValidator(
|
||||
if (callback) callback(valid)
|
||||
return valid
|
||||
}
|
||||
|
||||
const DATETIME_REGEX = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/
|
||||
|
||||
/**
|
||||
* Validator for the custom `intl-datetime` cell type. Expects SAS' ISO datetime
|
||||
* form `YYYY-MM-DD HH:mm:ss`. Mirrors HOT's intl validators by honouring
|
||||
* `this.allowEmpty` for blank values. Bound by HOT to the cell properties, so
|
||||
* `this` carries `allowEmpty`.
|
||||
*/
|
||||
export function datetimeValidator(
|
||||
this: { allowEmpty?: boolean } | void,
|
||||
value: any,
|
||||
callback: (valid: boolean) => void
|
||||
) {
|
||||
const allowEmpty = (this as { allowEmpty?: boolean })?.allowEmpty ?? true
|
||||
|
||||
if (value === undefined || value === null || value === '') {
|
||||
callback(allowEmpty)
|
||||
return
|
||||
}
|
||||
|
||||
callback(typeof value === 'string' && DATETIME_REGEX.test(value))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import Handsontable from 'handsontable'
|
||||
import { MenuItemConfig } from 'handsontable/plugins/contextMenu'
|
||||
|
||||
export type HotExportFormat = 'csv' | 'xlsx'
|
||||
|
||||
/**
|
||||
* Cell types whose values must be exported as raw stored strings rather than
|
||||
* converted to native Excel date/time cells. SAS raw data is the canonical
|
||||
* representation, so dates/times are exported exactly as the grid stores them.
|
||||
* `numeric` is intentionally NOT listed — it stays a native Excel number.
|
||||
*/
|
||||
const RAW_EXPORT_TYPES = new Set([
|
||||
'date',
|
||||
'time',
|
||||
'datetime',
|
||||
'intl-date',
|
||||
'intl-time',
|
||||
'intl-datetime'
|
||||
])
|
||||
|
||||
/**
|
||||
* Client-side export of a Handsontable grid's current view via the built-in
|
||||
* `exportFile` plugin. Exports the current selection if one exists, otherwise
|
||||
* the whole grid. This is a "what you see" convenience export and is distinct
|
||||
* from any server-side (SAS) download.
|
||||
*
|
||||
* @param hot Live Handsontable instance.
|
||||
* @param format `'csv'` or `'xlsx'`.
|
||||
* @param filename File name (without extension).
|
||||
* @param skipLeadingCols Number of leading UI/housekeeping columns to exclude
|
||||
* (e.g. the editor's `Delete?` column at index 0).
|
||||
*/
|
||||
export async function exportGrid(
|
||||
hot: Handsontable,
|
||||
format: HotExportFormat,
|
||||
filename: string,
|
||||
skipLeadingCols = 0
|
||||
): Promise<void> {
|
||||
const plugin = hot.getPlugin('exportFile')
|
||||
|
||||
const opts: Record<string, any> = {
|
||||
filename: filename || 'export',
|
||||
colHeaders: true
|
||||
}
|
||||
|
||||
// Selection-aware, contiguous range (visual indexes). Hidden columns are
|
||||
// excluded automatically (exportHiddenColumns defaults to false).
|
||||
const sel = hot.getSelectedRangeLast()
|
||||
const lastRow = hot.countRows() - 1
|
||||
const lastCol = hot.countCols() - 1
|
||||
|
||||
// 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
|
||||
// is select-all (negative coords) — both mean "export the whole table".
|
||||
const isCornerSelectAll = !!sel && sel.from.row < 0 && sel.from.col < 0
|
||||
if (sel && !sel.isSingleCell() && !isCornerSelectAll) {
|
||||
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 bottom = Math.max(sel.from.row, sel.to.row)
|
||||
const right = Math.max(sel.from.col, sel.to.col)
|
||||
opts['range'] = [top, Math.max(left, skipLeadingCols), bottom, right]
|
||||
}
|
||||
|
||||
if (!opts['range'] && skipLeadingCols > 0) {
|
||||
opts['range'] = [0, skipLeadingCols, lastRow, lastCol]
|
||||
}
|
||||
|
||||
if (format === 'csv') {
|
||||
// CSV reads raw stored values (getDataAtCell) — no type conversion needed.
|
||||
plugin.downloadFile('csv', opts)
|
||||
return
|
||||
}
|
||||
|
||||
// xlsx: temporarily override cell meta during the export's single
|
||||
// getCellMeta() pass, then restore (HOT returns cached meta objects).
|
||||
// - date/time/datetime types -> 'text' so values export raw, not as native
|
||||
// Excel dates.
|
||||
// - drop color/highlight: clear classNames and readOnly so the exporter
|
||||
// doesn't bake in theme/validation/read-only fills. Cell classes carry no
|
||||
// meaning in the exported file.
|
||||
const restore: Array<{
|
||||
meta: any
|
||||
type: any
|
||||
className: any
|
||||
readOnly: any
|
||||
}> = []
|
||||
const seen = new Set<any>()
|
||||
const hook = (_row: number, _col: number, cp: any): void => {
|
||||
if (seen.has(cp)) return
|
||||
seen.add(cp)
|
||||
|
||||
const needsTypeFix = RAW_EXPORT_TYPES.has(cp.type)
|
||||
const needsClassFix =
|
||||
typeof cp.className === 'string' && cp.className !== ''
|
||||
const needsReadOnlyFix = cp.readOnly === true
|
||||
|
||||
if (!needsTypeFix && !needsClassFix && !needsReadOnlyFix) return
|
||||
|
||||
restore.push({
|
||||
meta: cp,
|
||||
type: cp.type,
|
||||
className: cp.className,
|
||||
readOnly: cp.readOnly
|
||||
})
|
||||
|
||||
if (needsTypeFix) cp.type = 'text'
|
||||
if (needsClassFix) cp.className = ''
|
||||
if (needsReadOnlyFix) cp.readOnly = false
|
||||
}
|
||||
|
||||
hot.addHook('afterGetCellMeta', hook)
|
||||
try {
|
||||
const mod: any = await import('exceljs')
|
||||
const ExcelJS = mod.default ?? mod
|
||||
await plugin.downloadFileAsync('xlsx', { ...opts, engine: ExcelJS })
|
||||
} finally {
|
||||
hot.removeHook('afterGetCellMeta', hook)
|
||||
restore.forEach(({ meta, type, className, readOnly }) => {
|
||||
meta.type = type
|
||||
meta.className = className
|
||||
meta.readOnly = readOnly
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a reusable `export_file` context-menu item (CSV / Excel submenu) that
|
||||
* exports the given grid via {@link exportGrid}. Add it to any HOT context-menu
|
||||
* `items` map under the `export_file` key.
|
||||
*
|
||||
* @param getFilename Returns the export file name at click time.
|
||||
* @param skipLeadingCols Leading UI columns to exclude (see {@link exportGrid}).
|
||||
*/
|
||||
export function buildExportMenuItem(
|
||||
getFilename: () => string,
|
||||
skipLeadingCols = 0
|
||||
): MenuItemConfig {
|
||||
// HOT invokes context-menu callbacks with `this` bound to the clicked grid
|
||||
// instance, so the item works uniformly even for components that host
|
||||
// multiple grids (e.g. viewboxes).
|
||||
const makeCb = (format: HotExportFormat) =>
|
||||
function (this: Handsontable): void {
|
||||
void exportGrid(this, format, getFilename(), skipLeadingCols)
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'Export',
|
||||
// The exportFile plugin registers a predefined item under the same
|
||||
// `export_file` key whose `hidden()` returns true unless an `exportFile`
|
||||
// setting exists. HOT shallow-merges our config over it, so we must
|
||||
// explicitly override `hidden` to keep the item visible.
|
||||
hidden: false,
|
||||
submenu: {
|
||||
// Submenu keys use the documented "parent_key:sub_key" form. The array
|
||||
// form is what HOT's own exportFile plugin uses at runtime; cast to
|
||||
// satisfy the (stricter) MenuConfig typing.
|
||||
items: [
|
||||
{
|
||||
key: 'export_file:csv',
|
||||
name: 'CSV',
|
||||
callback: makeCb('csv')
|
||||
},
|
||||
{
|
||||
key: 'export_file:xlsx',
|
||||
name: 'Excel (.xlsx)',
|
||||
callback: makeCb('xlsx')
|
||||
}
|
||||
] as any
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import { DirectivesModule } from '../directives/directives.module'
|
||||
import { DatasetInfoComponent } from './dataset-info/dataset-info.component'
|
||||
import { ContactLinkComponent } from './contact-link/contact-link.component'
|
||||
import { ExcelPasswordModalComponent } from './excel-password-modal/excel-password-modal.component'
|
||||
import { ConfirmModalComponent } from './confirm-modal/confirm-modal.component'
|
||||
import { BulkValidationModalComponent } from './bulk-validation-modal/bulk-validation-modal.component'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
@@ -32,7 +34,9 @@ import { ExcelPasswordModalComponent } from './excel-password-modal/excel-passwo
|
||||
TermsComponent,
|
||||
DatasetInfoComponent,
|
||||
ContactLinkComponent,
|
||||
ExcelPasswordModalComponent
|
||||
ExcelPasswordModalComponent,
|
||||
ConfirmModalComponent,
|
||||
BulkValidationModalComponent
|
||||
],
|
||||
exports: [
|
||||
LoadingIndicatorComponent,
|
||||
@@ -42,7 +46,9 @@ import { ExcelPasswordModalComponent } from './excel-password-modal/excel-passwo
|
||||
TermsComponent,
|
||||
DatasetInfoComponent,
|
||||
ContactLinkComponent,
|
||||
ExcelPasswordModalComponent
|
||||
ExcelPasswordModalComponent,
|
||||
ConfirmModalComponent,
|
||||
BulkValidationModalComponent
|
||||
],
|
||||
providers: [UserService, AlertsService]
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<clr-dropdown>
|
||||
<button class="dropdown-toggle btn btn-primary" clrDropdownTrigger>
|
||||
{{ getSubPage() }}
|
||||
<clr-icon shape="caret down"></clr-icon>
|
||||
<clr-icon aria-hidden="true" shape="caret down"></clr-icon>
|
||||
</button>
|
||||
<clr-dropdown-menu *clrIfOpen>
|
||||
<a
|
||||
@@ -76,7 +76,9 @@
|
||||
class="user-nav-btn"
|
||||
clrTabLink
|
||||
>
|
||||
<p class="zero-margin"><clr-icon shape="user"></clr-icon> Users</p>
|
||||
<p class="zero-margin">
|
||||
<clr-icon aria-hidden="true" shape="user"></clr-icon> Users
|
||||
</p>
|
||||
</button>
|
||||
<clr-tab-content> </clr-tab-content>
|
||||
</clr-tab>
|
||||
@@ -87,7 +89,9 @@
|
||||
class="user-nav-btn"
|
||||
clrTabLink
|
||||
>
|
||||
<p class="zero-margin"><clr-icon shape="users"></clr-icon> Groups</p>
|
||||
<p class="zero-margin">
|
||||
<clr-icon aria-hidden="true" shape="users"></clr-icon> Groups
|
||||
</p>
|
||||
</button>
|
||||
<clr-tab-content *clrIfActive="isMainRoute('/view/usernav/groups')">
|
||||
</clr-tab-content>
|
||||
@@ -100,7 +104,7 @@
|
||||
clrTabLink
|
||||
>
|
||||
<p class="zero-margin">
|
||||
<clr-icon shape="blocks-group"></clr-icon> Roles
|
||||
<clr-icon aria-hidden="true" shape="blocks-group"></clr-icon> Roles
|
||||
</p>
|
||||
</button>
|
||||
<clr-tab-content *clrIfActive="isMainRoute('/view/usernav/roles')">
|
||||
@@ -115,7 +119,7 @@
|
||||
<clr-dropdown>
|
||||
<button class="dropdown-toggle btn btn-primary" clrDropdownTrigger>
|
||||
{{ getSubPage() }}
|
||||
<clr-icon shape="caret down"></clr-icon>
|
||||
<clr-icon aria-hidden="true" shape="caret down"></clr-icon>
|
||||
</button>
|
||||
<clr-dropdown-menu *clrIfOpen>
|
||||
<a
|
||||
|
||||
@@ -80,15 +80,13 @@ export class SidebarComponent implements OnInit {
|
||||
public resizeStart() {
|
||||
this.resizing = true
|
||||
|
||||
let body = document.getElementsByTagName('body')[0]
|
||||
body.style.cssText = 'user-select: none'
|
||||
document.body.classList.add('select-none')
|
||||
}
|
||||
|
||||
public resizeEnd() {
|
||||
this.resizing = false
|
||||
|
||||
let body = document.getElementsByTagName('body')[0]
|
||||
body.style.cssText = ''
|
||||
document.body.classList.remove('select-none')
|
||||
}
|
||||
|
||||
@HostListener('document:mousemove', ['$event'])
|
||||
|
||||
@@ -816,6 +816,7 @@ export class SpreadsheetUtil {
|
||||
data.forEach((row: any[]) => {
|
||||
dateCols.forEach((element) => {
|
||||
const obj = row[element]
|
||||
if (!obj || obj.v == null) return
|
||||
if (isStringNumber(obj.v)) {
|
||||
const date = excelDateToJSDate(Number(obj.v))
|
||||
|
||||
@@ -847,6 +848,7 @@ export class SpreadsheetUtil {
|
||||
data.forEach((row: any[]) => {
|
||||
timeCols.forEach((element) => {
|
||||
const obj = row[element]
|
||||
if (!obj || obj.v == null) return
|
||||
if (
|
||||
isStringNumber(obj.v) ||
|
||||
isStringDecimal(obj.v) ||
|
||||
@@ -870,6 +872,7 @@ export class SpreadsheetUtil {
|
||||
data.forEach((row: any[]) => {
|
||||
dateTimeCols.forEach((element) => {
|
||||
const obj = row[element]
|
||||
if (!obj || obj.v == null) return
|
||||
if (isStringNumber(obj.v) || isStringDecimal(obj.v)) {
|
||||
const date = excelDateToJSDate(Number(obj.v))
|
||||
obj.v = dateFormat(date) + ' ' + dateToUtcTime(date)
|
||||
@@ -903,6 +906,7 @@ export class SpreadsheetUtil {
|
||||
data.forEach((row: any[]) => {
|
||||
xlRuleCols.forEach((element: any) => {
|
||||
const obj = row[element.index]
|
||||
if (!obj) return
|
||||
if (element.XL_RULE === 'FORMULA') {
|
||||
if ('f' in obj) {
|
||||
if (obj['t'] === 'n') {
|
||||
@@ -912,7 +916,7 @@ export class SpreadsheetUtil {
|
||||
}
|
||||
}
|
||||
}
|
||||
row[element] = obj
|
||||
row[element.index] = obj
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -43,7 +43,11 @@
|
||||
[class.hidden]="debugLogs.length === 0"
|
||||
>{{ debugLogs.length }}</span
|
||||
>
|
||||
<clr-icon *ngIf="!isViya" shape="caret down"></clr-icon>
|
||||
<clr-icon
|
||||
*ngIf="!isViya"
|
||||
aria-hidden="true"
|
||||
shape="caret down"
|
||||
></clr-icon>
|
||||
</button>
|
||||
<clr-dropdown-menu clrPosition="bottom-right" *clrIfOpen>
|
||||
<div #dropdownItemDebug class="debug-switch-item" clrDropdownItem>
|
||||
@@ -83,7 +87,11 @@
|
||||
</a>
|
||||
<a href="..." (click)="logout($event)" clrDropdownItem>
|
||||
<span>Log Out</span>
|
||||
<clr-icon class="clr-logout" shape="logout"></clr-icon>
|
||||
<clr-icon
|
||||
aria-hidden="true"
|
||||
class="clr-logout"
|
||||
shape="logout"
|
||||
></clr-icon>
|
||||
</a>
|
||||
<div class="copyRight">
|
||||
<span>v{{ commitVer }}</span>
|
||||
|
||||
@@ -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,6 @@
|
||||
/**
|
||||
* Strips characters that could cause SAS macro injection (& % ;).
|
||||
*/
|
||||
export function sanitiseForSas(input: string): string {
|
||||
return input.replace(/[%&;]/g, '')
|
||||
}
|
||||
@@ -50,7 +50,7 @@
|
||||
aria-haspopup="true"
|
||||
class="tooltip tooltip-md tooltip-bottom-left ml-3"
|
||||
>
|
||||
<clr-icon shape="grid-view" size="19"></clr-icon>
|
||||
<clr-icon aria-hidden="true" shape="grid-view" size="19"></clr-icon>
|
||||
<span class="tooltip-content">Snap viewboxes to grid</span>
|
||||
</a>
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
aria-haspopup="true"
|
||||
class="tooltip tooltip-md tooltip-bottom-left ml-3"
|
||||
>
|
||||
<clr-icon shape="minus" size="19"></clr-icon>
|
||||
<clr-icon aria-hidden="true" shape="minus" size="19"></clr-icon>
|
||||
<span class="tooltip-content">Minimize all viewboxes</span>
|
||||
</a>
|
||||
|
||||
@@ -70,7 +70,11 @@
|
||||
aria-haspopup="true"
|
||||
class="tooltip tooltip-md tooltip-bottom-left ml-3"
|
||||
>
|
||||
<clr-icon shape="window-restore" size="19"></clr-icon>
|
||||
<clr-icon
|
||||
aria-hidden="true"
|
||||
shape="window-restore"
|
||||
size="19"
|
||||
></clr-icon>
|
||||
<span class="tooltip-content">Restore all viewboxes</span>
|
||||
</a>
|
||||
</div>
|
||||
@@ -105,7 +109,11 @@
|
||||
class="tooltip tooltip-md tooltip-bottom-left"
|
||||
[class.disabled]="viewbox.x === 0 && viewbox.y === 0"
|
||||
>
|
||||
<clr-icon shape="cursor-move" size="13"></clr-icon>
|
||||
<clr-icon
|
||||
aria-hidden="true"
|
||||
shape="cursor-move"
|
||||
size="13"
|
||||
></clr-icon>
|
||||
<span class="tooltip-content">Reset position and size</span>
|
||||
</a>
|
||||
|
||||
@@ -116,7 +124,7 @@
|
||||
aria-haspopup="true"
|
||||
class="tooltip tooltip-md tooltip-bottom-left ml-3"
|
||||
>
|
||||
<clr-icon shape="minus" size="19"></clr-icon>
|
||||
<clr-icon aria-hidden="true" shape="minus" size="19"></clr-icon>
|
||||
<span class="tooltip-content">Minimize viewbox</span>
|
||||
</a>
|
||||
|
||||
@@ -127,7 +135,11 @@
|
||||
aria-haspopup="true"
|
||||
class="tooltip tooltip-md tooltip-bottom-left ml-3"
|
||||
>
|
||||
<clr-icon shape="window-restore" size="19"></clr-icon>
|
||||
<clr-icon
|
||||
aria-hidden="true"
|
||||
shape="window-restore"
|
||||
size="19"
|
||||
></clr-icon>
|
||||
<span class="tooltip-content">Restore viewbox</span>
|
||||
</a>
|
||||
|
||||
@@ -137,7 +149,7 @@
|
||||
aria-haspopup="true"
|
||||
class="tooltip tooltip-md tooltip-bottom-left ml-3"
|
||||
>
|
||||
<clr-icon shape="times" size="19"></clr-icon>
|
||||
<clr-icon aria-hidden="true" shape="times" size="19"></clr-icon>
|
||||
<span class="tooltip-content">Close viewbox</span>
|
||||
</a>
|
||||
</div>
|
||||
@@ -226,6 +238,7 @@
|
||||
}}
|
||||
<clr-icon
|
||||
*ngIf="hotColumns.headerPks.includes(column)"
|
||||
aria-hidden="true"
|
||||
shape="key"
|
||||
></clr-icon>
|
||||
</span>
|
||||
@@ -233,6 +246,7 @@
|
||||
<clr-icon
|
||||
*ngIf="!hotColumns.headerPks.includes(column)"
|
||||
(click)="onColRemove(column)"
|
||||
aria-label="Remove column"
|
||||
shape="trash"
|
||||
class="cursor-pointer"
|
||||
></clr-icon>
|
||||
@@ -284,15 +298,25 @@
|
||||
<clr-icon
|
||||
*ngIf="!viewbox.collapsed"
|
||||
(click)="collapse(viewbox)"
|
||||
aria-label="Collapse"
|
||||
shape="angle top"
|
||||
></clr-icon>
|
||||
<clr-icon
|
||||
*ngIf="viewbox.collapsed"
|
||||
(click)="expand(viewbox)"
|
||||
aria-label="Expand"
|
||||
shape="angle down"
|
||||
></clr-icon>
|
||||
<clr-icon (click)="minimize(viewbox)" shape="minus"></clr-icon>
|
||||
<clr-icon (click)="close(viewbox)" shape="times"></clr-icon>
|
||||
<clr-icon
|
||||
(click)="minimize(viewbox)"
|
||||
aria-label="Minimize"
|
||||
shape="minus"
|
||||
></clr-icon>
|
||||
<clr-icon
|
||||
(click)="close(viewbox)"
|
||||
aria-label="Close"
|
||||
shape="times"
|
||||
></clr-icon>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
@@ -320,6 +344,7 @@
|
||||
<clr-icon
|
||||
*ngIf="!viewbox.searchLoading"
|
||||
(click)="searchTable(searchEl, viewbox)"
|
||||
aria-label="Search"
|
||||
shape="search"
|
||||
></clr-icon>
|
||||
<span *ngIf="viewbox.searchLoading" class="spinner spinner-inline">
|
||||
@@ -347,6 +372,7 @@
|
||||
<clr-icon
|
||||
*ngIf="!viewbox.loadingData"
|
||||
(click)="reloadTableData(viewbox); searchEl.value = ''"
|
||||
aria-label="Reload data"
|
||||
shape="refresh"
|
||||
class="click-icon"
|
||||
></clr-icon>
|
||||
@@ -357,6 +383,7 @@
|
||||
class="tooltip tooltip-lg tooltip-bottom-left ml-5"
|
||||
>
|
||||
<clr-icon
|
||||
aria-hidden="true"
|
||||
shape="filter"
|
||||
[class.filter-active]="viewbox.filter_pk !== '0'"
|
||||
class="click-icon"
|
||||
@@ -368,6 +395,7 @@
|
||||
<clr-icon
|
||||
(click)="openTableEdit(viewbox)"
|
||||
[class.disabled]="!tableEditExists(viewbox)"
|
||||
aria-label="Edit table"
|
||||
shape="pencil"
|
||||
class="click-icon ml-5"
|
||||
></clr-icon>
|
||||
|
||||
@@ -39,6 +39,7 @@ import { AutocompleteComponent } from 'src/app/shared/autocomplete/autocomplete.
|
||||
import { LibraryClickEmitter } from 'src/app/shared/dc-tree/models/LibraryClickEmitter'
|
||||
import { TableClickEmitter } from 'src/app/shared/dc-tree/models/TableClickEmitter'
|
||||
import { mergeColsRules } from 'src/app/shared/dc-validator/utils/mergeColsRules'
|
||||
import { buildExportMenuItem } from 'src/app/shared/hot-export/hot-export.util'
|
||||
import { globals, initFilter } from 'src/app/_globals'
|
||||
import { ViewboxHotTable } from './models/viewbox-hot-table.model'
|
||||
import { ViewboxTable } from './models/viewbox-table.model'
|
||||
@@ -526,7 +527,7 @@ export class ViewboxesComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
const viewboxTable = this.viewboxTables[viewboxTableIndex]
|
||||
const calculatedHeight = this.calculateTableHeight(viewbox)
|
||||
|
||||
// HOT v16 settings - data will be loaded manually after initialization
|
||||
// HOT v17 settings - data will be loaded manually after initialization
|
||||
const settings: Handsontable.GridSettings = {
|
||||
colHeaders: viewboxTable.hotTable.colHeaders,
|
||||
columns: viewboxTable.hotTable.columns,
|
||||
@@ -534,7 +535,15 @@ export class ViewboxesComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
readOnly: true,
|
||||
modifyColWidth: this.maxWidthCheker,
|
||||
copyPaste: viewboxTable.hotTable.copyPaste,
|
||||
contextMenu: viewboxTable.hotTable.contextMenu,
|
||||
// Built here (not in hotTableDefault) because that template is cloneDeep'd,
|
||||
// which would strip the export callbacks. Client-side view export.
|
||||
contextMenu: {
|
||||
items: {
|
||||
copy_with_column_headers: {},
|
||||
copy_column_headers_only: {},
|
||||
export_file: buildExportMenuItem(() => 'viewbox')
|
||||
}
|
||||
},
|
||||
multiColumnSorting: true,
|
||||
viewportRowRenderingOffset: 50,
|
||||
filters: true,
|
||||
@@ -544,7 +553,8 @@ export class ViewboxesComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
maxRows: viewboxTable.hotTable.maxRows || Infinity,
|
||||
manualColumnResize: true,
|
||||
rowHeaders: true,
|
||||
licenseKey: viewboxTable.hotTable.licenseKey
|
||||
licenseKey: viewboxTable.hotTable.licenseKey,
|
||||
theme: 'ht-theme-classic'
|
||||
}
|
||||
|
||||
// Force a new object reference to trigger change detection
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="content-area">
|
||||
<main class="content-area">
|
||||
<div class="clr-col-md-12 card">
|
||||
<div
|
||||
*ngIf="!loaded"
|
||||
@@ -127,7 +127,7 @@
|
||||
<hot-table
|
||||
id="hotTable"
|
||||
[data]="hotTable.data"
|
||||
[settings]="hotTableSettings"
|
||||
[settings]="hotTableSettings()"
|
||||
aria-label="Staged data table"
|
||||
>
|
||||
<!--[licenseKey]=null-->
|
||||
@@ -137,4 +137,4 @@
|
||||
<div class="card-footer d-flex justify-content-center"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -2,8 +2,8 @@ import {
|
||||
Component,
|
||||
OnInit,
|
||||
ViewEncapsulation,
|
||||
ViewChild,
|
||||
AfterViewInit
|
||||
AfterViewInit,
|
||||
signal
|
||||
} from '@angular/core'
|
||||
import { SasStoreService } from '../services/sas-store.service'
|
||||
import { Router } from '@angular/router'
|
||||
@@ -63,7 +63,12 @@ export class StageComponent implements OnInit, AfterViewInit {
|
||||
}
|
||||
}
|
||||
|
||||
get hotTableSettings(): Handsontable.GridSettings {
|
||||
// Stable settings reference so the HOT v17 wrapper doesn't re-run
|
||||
// updateSettings on every change-detection tick. Updated only when the data/
|
||||
// columns or license change.
|
||||
hotTableSettings = signal<Handsontable.GridSettings>(this.buildHotSettings())
|
||||
|
||||
private buildHotSettings(): Handsontable.GridSettings {
|
||||
return {
|
||||
...this.hotTable.settings,
|
||||
colHeaders: this.hotTable.colHeaders,
|
||||
@@ -75,7 +80,8 @@ export class StageComponent implements OnInit, AfterViewInit {
|
||||
afterInit: this.hotTable.afterInit,
|
||||
stretchH: 'all',
|
||||
cells: this.hotTable.cells,
|
||||
className: 'htDark'
|
||||
className: 'htDark',
|
||||
theme: 'ht-theme-classic'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +137,7 @@ export class StageComponent implements OnInit, AfterViewInit {
|
||||
this.licenceService.hot_license_key.subscribe(
|
||||
(hot_license_key: string | undefined) => {
|
||||
this.hotTable.licenseKey = hot_license_key
|
||||
this.hotTableSettings.set(this.buildHotSettings())
|
||||
}
|
||||
)
|
||||
|
||||
@@ -190,6 +197,7 @@ export class StageComponent implements OnInit, AfterViewInit {
|
||||
this.hotTable.colHeaders = colHeaders
|
||||
this.hotTable.columns = columns
|
||||
this.hotTable.cells = cells
|
||||
this.hotTableSettings.set(this.buildHotSettings())
|
||||
|
||||
this.loaded = true
|
||||
this.setFocus()
|
||||
|
||||
@@ -13,7 +13,7 @@ const routes: Routes = [{ path: ':tableId', component: StageComponent }]
|
||||
CommonModule,
|
||||
ClarityModule,
|
||||
RouterModule.forChild(routes),
|
||||
HotTableModule.forRoot()
|
||||
HotTableModule
|
||||
]
|
||||
})
|
||||
export class StageModule {}
|
||||
|
||||
@@ -236,14 +236,31 @@
|
||||
<div class="admin-action">
|
||||
Download Configuration
|
||||
|
||||
<input
|
||||
type="text"
|
||||
class="clr-input libref-input"
|
||||
maxlength="8"
|
||||
[ngModel]="dcLib"
|
||||
(ngModelChange)="targetLibref = $event.toUpperCase()"
|
||||
placeholder="Target Libref"
|
||||
/>
|
||||
<div class="libref-group">
|
||||
<clr-tooltip class="libref-tooltip">
|
||||
<label clrTooltipTrigger class="libref-label">
|
||||
Target DC Library
|
||||
<cds-icon shape="info-circle" size="16"></cds-icon>
|
||||
</label>
|
||||
<clr-tooltip-content
|
||||
clrPosition="bottom-left"
|
||||
clrSize="md"
|
||||
*clrIfOpen
|
||||
>
|
||||
Enter the target DC library and the downloaded files will
|
||||
contain this, instead of the original.
|
||||
</clr-tooltip-content>
|
||||
</clr-tooltip>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
class="clr-input libref-input"
|
||||
maxlength="8"
|
||||
[ngModel]="dcLib"
|
||||
(ngModelChange)="targetLibref = $event.toUpperCase()"
|
||||
placeholder="e.g. MYLIB"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
(click)="downloadConfiguration()"
|
||||
[disabled]="targetLibref !== dcLib && !isValidLibref(targetLibref)"
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
.libref-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.libref-label {
|
||||
cursor: pointer;
|
||||
font-size: 0.55rem;
|
||||
font-weight: 600;
|
||||
color: var(--clr-p4-color, #565656);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.libref-input {
|
||||
width: 100px;
|
||||
margin: 0 8px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@@ -19,11 +19,13 @@
|
||||
|
||||
<clr-icon
|
||||
*ngIf="searchLibTreeInput.value.length < 1"
|
||||
aria-hidden="true"
|
||||
shape="search"
|
||||
></clr-icon>
|
||||
<clr-icon
|
||||
*ngIf="searchLibTreeInput.value.length > 0"
|
||||
(click)="librariesSearch = ''; libraryOnFilter()"
|
||||
aria-label="Clear search"
|
||||
shape="times"
|
||||
></clr-icon>
|
||||
</div>
|
||||
@@ -44,7 +46,7 @@
|
||||
"
|
||||
class="m-0 cursor-pointer"
|
||||
>
|
||||
<clr-icon shape="rack-server"></clr-icon>
|
||||
<clr-icon aria-hidden="true" shape="rack-server"></clr-icon>
|
||||
{{ library.LIBRARYNAME }}
|
||||
</p>
|
||||
|
||||
@@ -64,6 +66,7 @@
|
||||
|
||||
<clr-icon
|
||||
*ngIf="searchTreeInput.value.length < 1"
|
||||
aria-hidden="true"
|
||||
shape="search"
|
||||
></clr-icon>
|
||||
<clr-icon
|
||||
@@ -73,6 +76,7 @@
|
||||
library['searchString'] = '';
|
||||
treeOnFilter(library, 'tables')
|
||||
"
|
||||
aria-label="Clear search"
|
||||
shape="times"
|
||||
></clr-icon>
|
||||
</div>
|
||||
@@ -96,8 +100,16 @@
|
||||
[class.active]="libTabActive(library.LIBRARYREF, libTable)"
|
||||
>
|
||||
<ng-container [ngSwitch]="libTable.includes('-FC')">
|
||||
<clr-icon *ngSwitchCase="true" shape="bolt"></clr-icon>
|
||||
<clr-icon *ngSwitchCase="false" shape="table"></clr-icon>
|
||||
<clr-icon
|
||||
*ngSwitchCase="true"
|
||||
aria-hidden="true"
|
||||
shape="bolt"
|
||||
></clr-icon>
|
||||
<clr-icon
|
||||
*ngSwitchCase="false"
|
||||
aria-hidden="true"
|
||||
shape="table"
|
||||
></clr-icon>
|
||||
</ng-container>
|
||||
{{ libTable.replace('-FC', '') }}
|
||||
</button>
|
||||
@@ -345,6 +357,7 @@
|
||||
<clr-icon
|
||||
*ngIf="!searchLoading"
|
||||
(click)="searchTable(searchEl)"
|
||||
aria-label="Search"
|
||||
shape="search"
|
||||
></clr-icon>
|
||||
<span *ngIf="searchLoading" class="spinner spinner-inline">
|
||||
@@ -383,6 +396,7 @@
|
||||
|
||||
<clr-icon
|
||||
*ngIf="tableTitle?.includes('-FC')"
|
||||
aria-hidden="true"
|
||||
shape="bolt"
|
||||
class="color-yellow mr-5"
|
||||
></clr-icon>
|
||||
@@ -411,6 +425,7 @@
|
||||
|
||||
<clr-icon
|
||||
(click)="reloadTableData()"
|
||||
aria-label="Reload data"
|
||||
class="refresh-table"
|
||||
shape="refresh"
|
||||
></clr-icon>
|
||||
@@ -429,12 +444,12 @@
|
||||
class="btn btn-sm btn-outline filterSide"
|
||||
clrDropdownTrigger
|
||||
>
|
||||
<clr-icon shape="cog" size="15"></clr-icon>
|
||||
<clr-icon aria-hidden="true" shape="cog" size="15"></clr-icon>
|
||||
options
|
||||
</button>
|
||||
<clr-dropdown-menu clrPosition="bottom-right" *clrIfOpen>
|
||||
<div (click)="newViewbox()" clrDropdownItem>
|
||||
<clr-icon shape="view-cards"></clr-icon>
|
||||
<clr-icon aria-hidden="true" shape="view-cards"></clr-icon>
|
||||
<span>Viewboxes</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -442,23 +457,23 @@
|
||||
(click)="editTable()"
|
||||
clrDropdownItem
|
||||
>
|
||||
<clr-icon shape="pencil"></clr-icon>
|
||||
<clr-icon aria-hidden="true" shape="pencil"></clr-icon>
|
||||
<span>Edit</span>
|
||||
</div>
|
||||
<div *ngIf="tableuri" (click)="goToLineage()" clrDropdownItem>
|
||||
<clr-icon shape="switch"></clr-icon>
|
||||
<clr-icon aria-hidden="true" shape="switch"></clr-icon>
|
||||
<span>Lineage</span>
|
||||
</div>
|
||||
<div (click)="openQb()" clrDropdownItem>
|
||||
<clr-icon shape="filter"></clr-icon>
|
||||
<clr-icon aria-hidden="true" shape="filter"></clr-icon>
|
||||
<span>Filter</span>
|
||||
</div>
|
||||
<div (click)="openDownload = true" clrDropdownItem>
|
||||
<clr-icon shape="download"></clr-icon>
|
||||
<clr-icon aria-hidden="true" shape="download"></clr-icon>
|
||||
<span>Download</span>
|
||||
</div>
|
||||
<div (click)="showWebQuery()" clrDropdownItem>
|
||||
<clr-icon shape="download-cloud"></clr-icon>
|
||||
<clr-icon aria-hidden="true" shape="download-cloud"></clr-icon>
|
||||
<span>Web Query URL</span>
|
||||
</div>
|
||||
</clr-dropdown-menu>
|
||||
@@ -505,6 +520,7 @@
|
||||
</p>
|
||||
<clr-icon
|
||||
(click)="reloadLibInfo()"
|
||||
aria-label="Reload library info"
|
||||
class="refresh-table"
|
||||
shape="refresh"
|
||||
></clr-icon>
|
||||
@@ -536,6 +552,7 @@
|
||||
No library info found. Click
|
||||
<clr-icon
|
||||
(click)="reloadLibInfo()"
|
||||
aria-label="Reload library info"
|
||||
class="refresh-table m-0"
|
||||
shape="refresh"
|
||||
></clr-icon>
|
||||
@@ -610,7 +627,12 @@
|
||||
</ng-container>
|
||||
|
||||
<div *ngIf="noData || noDataReqErr" class="card-block noData">
|
||||
<clr-icon shape="warning-standard" size="60" class="is-info"></clr-icon>
|
||||
<clr-icon
|
||||
aria-hidden="true"
|
||||
shape="warning-standard"
|
||||
size="60"
|
||||
class="is-info"
|
||||
></clr-icon>
|
||||
<h3 *ngIf="noData" class="text-center color-gray">
|
||||
No data found with given conditions
|
||||
</h3>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { QueryComponent } from '../query/query.component'
|
||||
|
||||
import { FilterGroup, FilterQuery } from '../models/FilterQuery'
|
||||
import { HotTableInterface } from '../models/HotTable.interface'
|
||||
import { buildExportMenuItem } from '../shared/hot-export/hot-export.util'
|
||||
import { LoggerService } from '../services/logger.service'
|
||||
import Handsontable from 'handsontable'
|
||||
import {
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
Version
|
||||
} from '../models/sas/editors-getdata.model'
|
||||
import { mergeColsRules } from '../shared/dc-validator/utils/mergeColsRules'
|
||||
import { makeNumberFormatRenderer } from '../editor/utils/renderers.utils'
|
||||
import { PublicViewtablesServiceResponse } from '../models/sas/public-viewtables.model'
|
||||
import { PublicViewlibsServiceResponse } from '../models/sas/public-viewlibs.model'
|
||||
import { PublicRefreshlibinfoServiceResponse } from '../models/sas/public-refreshlibinfo.model'
|
||||
@@ -40,7 +42,6 @@ import { DataFormat } from '../models/sas/common/DateFormat'
|
||||
import { Libinfo } from '../models/sas/common/Libinfo'
|
||||
import { LicenceService } from '../services/licence.service'
|
||||
import { Location } from '@angular/common'
|
||||
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
|
||||
|
||||
@Component({
|
||||
selector: 'app-viewer',
|
||||
@@ -122,6 +123,7 @@ export class ViewerComponent
|
||||
stretchH: 'all',
|
||||
modifyColWidth: this.maxWidthCheker,
|
||||
cells: this.hotTable.cells,
|
||||
hiddenColumns: { columns: this.hiddenViewColumns, indicators: false },
|
||||
maxRows: this.hotTable.maxRows,
|
||||
manualColumnResize: true,
|
||||
afterGetColHeader: this.hotTable.afterGetColHeader,
|
||||
@@ -129,12 +131,15 @@ export class ViewerComponent
|
||||
rowHeaderWidth: this.hotTable.rowHeaderWidth,
|
||||
rowHeights: this.hotTable.rowHeights,
|
||||
licenseKey: this.hotTable.licenseKey,
|
||||
className: 'htDark'
|
||||
className: 'htDark',
|
||||
theme: 'ht-theme-classic'
|
||||
}
|
||||
}
|
||||
public numberOfRows: number | null = null
|
||||
public headerPks: string[] = []
|
||||
public $dataFormats: $DataFormats | null = null
|
||||
// Physical column indexes to hide in the viewer (from HIDDEN DQ rules)
|
||||
public hiddenViewColumns: number[] = []
|
||||
public datasetInfo: boolean = false
|
||||
public dsmeta: DSMeta[] = []
|
||||
public versions: Version[] = []
|
||||
@@ -181,7 +186,13 @@ export class ViewerComponent
|
||||
},
|
||||
rowHeaderWidth: 15,
|
||||
rowHeights: 20,
|
||||
contextMenu: ['copy_with_column_headers', 'copy_column_headers_only'],
|
||||
contextMenu: {
|
||||
items: {
|
||||
copy_with_column_headers: {},
|
||||
copy_column_headers_only: {},
|
||||
export_file: buildExportMenuItem(() => this.tableTitle || 'export')
|
||||
}
|
||||
},
|
||||
copyPaste: {
|
||||
copyColumnHeaders: true,
|
||||
copyColumnHeadersOnly: true
|
||||
@@ -769,18 +780,14 @@ export class ViewerComponent
|
||||
let ds = []
|
||||
ds = this.libDataset.split('.')
|
||||
|
||||
if (globals.viewer.startupSet) {
|
||||
this.libraries = globals.viewer.libraries
|
||||
} else {
|
||||
await this.sasStoreService
|
||||
.viewLibs()
|
||||
.then((res: any) => {
|
||||
this.libraries = res.saslibs
|
||||
})
|
||||
.catch((err: any) => {
|
||||
this.loggerService.error(err)
|
||||
})
|
||||
}
|
||||
await this.sasStoreService
|
||||
.viewLibs()
|
||||
.then((res: any) => {
|
||||
this.libraries = res.saslibs
|
||||
})
|
||||
.catch((err: any) => {
|
||||
this.loggerService.error(err)
|
||||
})
|
||||
|
||||
this.lib = ds[0]
|
||||
|
||||
@@ -815,18 +822,14 @@ export class ViewerComponent
|
||||
libDataset = this.libDataset
|
||||
this.libTab = libDataset
|
||||
} else {
|
||||
if (globals.viewer.startupSet) {
|
||||
this.libraries = globals.viewer.libraries
|
||||
} else {
|
||||
await this.sasStoreService
|
||||
.viewLibs()
|
||||
.then((res: any) => {
|
||||
this.libraries = res.saslibs
|
||||
})
|
||||
.catch((err: any) => {
|
||||
this.loggerService.error(err)
|
||||
})
|
||||
}
|
||||
await this.sasStoreService
|
||||
.viewLibs()
|
||||
.then((res: any) => {
|
||||
this.libraries = res.saslibs
|
||||
})
|
||||
.catch((err: any) => {
|
||||
this.loggerService.error(err)
|
||||
})
|
||||
|
||||
if (typeof this.table !== 'undefined') {
|
||||
if (globals.viewer.startupSet) {
|
||||
@@ -911,16 +914,43 @@ export class ViewerComponent
|
||||
}
|
||||
}
|
||||
|
||||
// NUMBER_FORMAT (display) and HIDDEN (visibility) are the DQ rules
|
||||
// that make sense in the read-only viewer; READONLY/ROUND are
|
||||
// editor-only. Build lookups for both from the response.
|
||||
const numberFormats: { [col: string]: string } = {}
|
||||
const hiddenCols = new Set<string>()
|
||||
if (Array.isArray(res.dqrules)) {
|
||||
for (const rule of res.dqrules) {
|
||||
if (rule.RULE_TYPE === 'NUMBER_FORMAT') {
|
||||
numberFormats[rule.BASE_COL] = rule.RULE_VALUE
|
||||
}
|
||||
if (rule.RULE_TYPE === 'HIDDEN') {
|
||||
hiddenCols.add(rule.BASE_COL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hiddenColumnIndexes: number[] = []
|
||||
for (let index = 0; index < colArr.length; index++) {
|
||||
columns.push({ data: colArr[index] })
|
||||
const col = colArr[index]
|
||||
const colDef: any = { data: col }
|
||||
if (numberFormats[col]) {
|
||||
colDef.renderer = makeNumberFormatRenderer(numberFormats[col])
|
||||
}
|
||||
if (hiddenCols.has(col)) {
|
||||
hiddenColumnIndexes.push(index)
|
||||
}
|
||||
columns.push(colDef)
|
||||
}
|
||||
|
||||
this.hotTable.colHeaders = colArr
|
||||
this.hotTable.columns = columns
|
||||
this.hiddenViewColumns = hiddenColumnIndexes
|
||||
} else {
|
||||
// Set empty arrays if no data
|
||||
this.hotTable.colHeaders = []
|
||||
this.hotTable.columns = []
|
||||
this.hiddenViewColumns = []
|
||||
}
|
||||
|
||||
// Set cells function
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user