Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6864c044dc | ||
|
|
b30ba3e76b | ||
|
|
a4583f2db0 | ||
|
|
a161670b86 | ||
|
|
e6cf5a8d25 | ||
|
|
7ebc26fc7f | ||
|
|
c9dd933cec | ||
|
|
716eba621f | ||
|
|
982d507ae3 | ||
|
|
27a12951e3 | ||
|
|
4c8e4ee1b2 | ||
|
|
a15fdc401e | ||
|
|
7019e39318 | ||
|
|
9ca57193b4 | ||
|
|
175eb9853a | ||
|
|
e859d3354e | ||
|
|
adc6424211 | ||
|
|
74442096ba | ||
|
|
9f84a580fb | ||
|
|
2f5d3dfc38 | ||
|
|
9b18a45d35 | ||
|
|
6a30de37ca | ||
|
|
31075a165f | ||
|
|
53b7ee0cb2 | ||
|
|
ebe6972dd7 | ||
|
|
2acea64589 | ||
|
|
ffa3ff9c10 | ||
|
|
0ac3ff4511 | ||
|
|
3429a7a2a6 | ||
|
|
56fb2c2a3a | ||
|
|
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 |
@@ -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.
|
||||
```
|
||||
@@ -69,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
|
||||
@@ -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
|
||||
@@ -123,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
|
||||
@@ -137,7 +144,9 @@ 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()
|
||||
|
||||
@@ -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
|
||||
@@ -155,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
|
||||
@@ -187,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
|
||||
@@ -266,17 +311,40 @@ jobs:
|
||||
surfer put --token ${{ secrets.CODE_DATACONTROLLER_IO }} --server code.datacontroller.io sasjsbuild/sasdocs/* /
|
||||
|
||||
- name: Upload assets to release
|
||||
env:
|
||||
# Use the same ephemeral per-job Gitea Actions token that the
|
||||
# "Create Empty Release" step uses for semantic-release. It is scoped
|
||||
# to this repo and is granted write access via the workflow's
|
||||
# `permissions:` block at the top of the file (see contents: write).
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
RELEASE_ID=`curl -k 'https://git.datacontroller.io/api/v1/repos/dc/dc/releases/latest?access_token=${{ secrets.RELEASE_TOKEN }}' | jq -r '.id'`
|
||||
RELEASE_BODY=`curl -k 'https://git.datacontroller.io/api/v1/repos/dc/dc/releases/latest?access_token=${{ secrets.RELEASE_TOKEN }}' | jq -r '.body'`
|
||||
# Update body
|
||||
curl --data '{"draft": false,"body":"'"$RELEASE_BODY\n\nFor installation instructions, please visit https://docs.datacontroller.io/"'"}' -X PATCH --header 'Content-Type: application/json' -k https://git.datacontroller.io/api/v1/repos/dc/dc/releases/$RELEASE_ID?access_token=${{ secrets.RELEASE_TOKEN }}
|
||||
set -euo pipefail
|
||||
# Send the token via Authorization header rather than ?access_token=
|
||||
# (the query-string form is deprecated and leaks into access logs).
|
||||
AUTH_HEADER="Authorization: token ${GITEA_TOKEN}"
|
||||
BASE="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
||||
|
||||
RELEASE_JSON=$(curl -k --fail-with-body -sS -H "$AUTH_HEADER" "$BASE/releases/latest")
|
||||
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id')
|
||||
RELEASE_BODY=$(echo "$RELEASE_JSON" | jq -r '.body')
|
||||
|
||||
# Update body (also confirms the token has contents:write on this repo)
|
||||
curl -k --fail-with-body -sS -X PATCH \
|
||||
-H "$AUTH_HEADER" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data "$(jq -n --arg body "$RELEASE_BODY"$'\n\nFor installation instructions, please visit https://docs.datacontroller.io/' \
|
||||
'{draft:false, body:$body}')" \
|
||||
"$BASE/releases/$RELEASE_ID"
|
||||
|
||||
# Upload assets
|
||||
URL="https://git.datacontroller.io/api/v1/repos/dc/dc/releases/$RELEASE_ID/assets?access_token=${{ secrets.RELEASE_TOKEN }}"
|
||||
curl -k $URL -F attachment=@frontend.zip
|
||||
curl -k $URL -F attachment=@sas/demostream_sas9.sas
|
||||
curl -k $URL -F attachment=@sas/viya.sas
|
||||
curl -k $URL -F attachment=@sas/sasjs_server.json.zip
|
||||
curl -k $URL -F attachment=@sas/sas9.sas
|
||||
curl -k $URL -F attachment=@sas/viya_noweb.sas
|
||||
curl -k $URL -F attachment=@sas/viya_noweb.json
|
||||
URL="$BASE/releases/$RELEASE_ID/assets"
|
||||
for f in frontend.zip \
|
||||
sas/demostream_sas9.sas \
|
||||
sas/viya.sas \
|
||||
sas/sasjs_server.json.zip \
|
||||
sas/sas9.sas \
|
||||
sas/viya_noweb.sas \
|
||||
sas/viya_noweb.json; do
|
||||
echo "Uploading $f ..."
|
||||
curl -k --fail-with-body -sS -H "$AUTH_HEADER" "$URL" -F "attachment=@$f"
|
||||
done
|
||||
|
||||
+104
@@ -1,3 +1,107 @@
|
||||
## [7.9.1](https://git.datacontroller.io/dc/dc/compare/v7.9.0...v7.9.1) (2026-06-30)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* workflow file upload using action PAT ([a161670](https://git.datacontroller.io/dc/dc/commit/a161670b8656614f61d42ee673064ba8f010b71c))
|
||||
|
||||
# [7.9.0](https://git.datacontroller.io/dc/dc/compare/v7.8.2...v7.9.0) (2026-06-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* allow large retained key values. Closes [#248](https://git.datacontroller.io/dc/dc/issues/248). ([6a30de3](https://git.datacontroller.io/dc/dc/commit/6a30de37cad21b7b94bf550ce7afbc804e6f0f90))
|
||||
* build issues ([#245](https://git.datacontroller.io/dc/dc/issues/245)) ([77b4bb3](https://git.datacontroller.io/dc/dc/commit/77b4bb310e62adb450805488fc69a132435d0eb7))
|
||||
* **ci:** bump Lighthouse to 13.4.0 ([47ba03e](https://git.datacontroller.io/dc/dc/commit/47ba03ec637113d8867372d64484927de9b645ff))
|
||||
* **dc-validator:** display date/time/datetime cells as raw ISO, locale-independent ([9ca5719](https://git.datacontroller.io/dc/dc/commit/9ca57193b4aafa1e7bf216301cdf197cea6ec4c9))
|
||||
* **editor:** await dynamic validation on paste; defer spinner ([ea03bde](https://git.datacontroller.io/dc/dc/commit/ea03bdecc5aaf917052add2c7e6fdc192b9974ce))
|
||||
* **editor:** cancelEdit will reset cell's valid state ([51071b4](https://git.datacontroller.io/dc/dc/commit/51071b463bb311c6db48c301daa8cc7dfd92144a))
|
||||
* **editor:** preserve commas in dropdown source values ([6af49cf](https://git.datacontroller.io/dc/dc/commit/6af49cf1fd8aa27cbf99f9514cca537339cdbf7e))
|
||||
* **editor:** retry initSetup when the Handsontable instance isn't ready ([53b7ee0](https://git.datacontroller.io/dc/dc/commit/53b7ee0cb23329fa4f9d6211ffe6b102053fade9))
|
||||
* **handsontable:** add exceljs ([38cb1e2](https://git.datacontroller.io/dc/dc/commit/38cb1e207b58822d71ef803e770a93bc7265bc1c))
|
||||
* **handsontable:** horizontal scrollbar in dropdown ([ac0bd10](https://git.datacontroller.io/dc/dc/commit/ac0bd102127ced6b6a908a7244f35002f22cb669))
|
||||
* **handsontable:** restore dark mode for v17 ([9d97bf7](https://git.datacontroller.io/dc/dc/commit/9d97bf7ea13199ab9dab10c5fc47e07b4751fb9a))
|
||||
* **handsontable:** update licenseChecker exclude buffers - exceljs dep ([12c7d30](https://git.datacontroller.io/dc/dc/commit/12c7d30894b5c3709b683189da6e52255ad0a3e7))
|
||||
* incrementing rk val ([7444209](https://git.datacontroller.io/dc/dc/commit/74442096bac1481a4992e5c97dac47c8d277cc3f))
|
||||
* **licensing:** add protocol info ([3668a74](https://git.datacontroller.io/dc/dc/commit/3668a7426fec078ad7939b5bc815d7ad920c0d62)), closes [#178](https://git.datacontroller.io/dc/dc/issues/178)
|
||||
* migrate handsontables to v17 ([1b73e35](https://git.datacontroller.io/dc/dc/commit/1b73e355b78a0039aa48f7a137095eb5470c08e2))
|
||||
* **mocks:** return dynamic cell values as OBJ to match new contract ([a15fdc4](https://git.datacontroller.io/dc/dc/commit/a15fdc401e6cdcf934a5d599f8b7c4d197e9d2d4))
|
||||
* removing display_value in SAS code ([9b18a45](https://git.datacontroller.io/dc/dc/commit/9b18a45d354ad617e590d62758ef761b01b3095a))
|
||||
* rule_type ([adc6424](https://git.datacontroller.io/dc/dc/commit/adc6424211a83fb03b267bf8fa6b6d9fa72f185b))
|
||||
* sending dynamic_values and dynamic_extended_values back as OBJ instead of ARR ([e859d33](https://git.datacontroller.io/dc/dc/commit/e859d3354e82f0faad53a0b8aa14ed66974cbffa))
|
||||
* upcasing RAW_VALUE, bumping core, fixing tests ([982d507](https://git.datacontroller.io/dc/dc/commit/982d507ae3ad2ccc08e65678956199cd54b696f2))
|
||||
* validate pasted values ([b661580](https://git.datacontroller.io/dc/dc/commit/b661580c604c2923b930911a1881aec7b06ecf23))
|
||||
* viewLibs now fires only once, libPromise shared between the calls ([dc4e07a](https://git.datacontroller.io/dc/dc/commit/dc4e07a692a5e839986abb69b17e51024c4cdc03))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **edit-record:** use native date/time/datetime pickers ([2d29346](https://git.datacontroller.io/dc/dc/commit/2d29346cbf09ad057743e6f5ebea12f3257f246e))
|
||||
* **editor:** add READONLY, HIDDEN, ROUND and NUMBER_FORMAT validation rules ([4ea604f](https://git.datacontroller.io/dc/dc/commit/4ea604f9fbdd429108fe063b306ea376e756c107))
|
||||
* **editor:** migrate date/time/datetime to HOT 17 Intl cell types ([f70ea2f](https://git.datacontroller.io/dc/dc/commit/f70ea2fe712ff9a42bf47387f9f0aaa6d1a1f511))
|
||||
* **editor:** paste-validation overlay with cancel and confirm ([609731f](https://git.datacontroller.io/dc/dc/commit/609731ff99185a162614df13bde7afb1a2c3bbca))
|
||||
* **editor:** SAS VA data-driven content embed mode (?embed=va) ([ffa3ff9](https://git.datacontroller.io/dc/dc/commit/ffa3ff9c103d6d80677f4f67e7d53418f201f491))
|
||||
* **editor:** VA embed filter modes with pending UX ([ebe6972](https://git.datacontroller.io/dc/dc/commit/ebe6972dd7d38c5742ed7eb446e0448d98b2c8a7))
|
||||
* **editor:** validate autofilled cells; fix paste validation lag ([11ee49a](https://git.datacontroller.io/dc/dc/commit/11ee49a57a0bc7ccd59e91d5fe37bb5db1bf7d69))
|
||||
* **handsontable:** enable export plugin, add export menu items ([29aaa72](https://git.datacontroller.io/dc/dc/commit/29aaa72c6047bb309d325887ab602a9369a1ad74))
|
||||
* migration script for adding new validation rules under [#241](https://git.datacontroller.io/dc/dc/issues/241) ([9f84a58](https://git.datacontroller.io/dc/dc/commit/9f84a580fb762da8c1c796a35eb4f531df1caf13))
|
||||
* new validation rules in mpe_selectbox. [#241](https://git.datacontroller.io/dc/dc/issues/241) ([2f5d3df](https://git.datacontroller.io/dc/dc/commit/2f5d3dfc387f02353601aaa844aaeabde3e9f3e5))
|
||||
|
||||
|
||||
### Reverts
|
||||
|
||||
* eager-load feature modules instead of lazy-loading ([3429a7a](https://git.datacontroller.io/dc/dc/commit/3429a7a2a6ecfa6115394e1c842061cc68bf2be3))
|
||||
* **editor:** DISPLAY_VALUE change ([eb015d7](https://git.datacontroller.io/dc/dc/commit/eb015d712b01687f36ac92e38ab576a7c114b1ba))
|
||||
|
||||
## [7.8.2](https://git.datacontroller.io/dc/dc/compare/v7.8.1...v7.8.2) (2026-05-20)
|
||||
|
||||
|
||||
### 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)
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -34,7 +34,14 @@ export default defineConfig({
|
||||
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;hyperformula@3.2.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
+1714
-623
File diff suppressed because it is too large
Load Diff
+14
-6
@@ -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",
|
||||
@@ -50,8 +54,8 @@
|
||||
"@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.7",
|
||||
"@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",
|
||||
@@ -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",
|
||||
@@ -134,6 +140,8 @@
|
||||
"watch": "^1.0.2"
|
||||
},
|
||||
"overrides": {
|
||||
"ajv": "8.18.0"
|
||||
"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'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
<hot-table
|
||||
id="hot-table"
|
||||
[data]="selectedTab === TabsEnum.Rules ? xlmapRules : xlData"
|
||||
[settings]="hotTableSettings"
|
||||
[settings]="hotTableSettings()"
|
||||
>
|
||||
</hot-table>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
HostBinding,
|
||||
OnInit,
|
||||
QueryList,
|
||||
signal,
|
||||
ViewChildren,
|
||||
ViewEncapsulation
|
||||
} from '@angular/core'
|
||||
@@ -138,7 +139,13 @@ export class XLMapComponent implements AfterContentInit, AfterViewInit, OnInit {
|
||||
public hotTableMaxRows =
|
||||
this.licenceState.value.viewer_rows_allowed || Infinity
|
||||
|
||||
get hotTableSettings(): Handsontable.GridSettings {
|
||||
// Stable settings reference so the HOT v17 wrapper doesn't re-run
|
||||
// updateSettings on every change-detection tick (the previous getter returned a
|
||||
// new object each call, causing an infinite render loop / freeze). Rebuilt only
|
||||
// when the selected tab, or the license, changes (see setSelectedTab + ngOnInit).
|
||||
hotTableSettings = signal<Handsontable.GridSettings>(this.buildHotSettings())
|
||||
|
||||
private buildHotSettings(): Handsontable.GridSettings {
|
||||
return {
|
||||
multiColumnSorting: true,
|
||||
viewportRowRenderingOffset: 50,
|
||||
@@ -162,10 +169,17 @@ export class XLMapComponent implements AfterContentInit, AfterViewInit, OnInit {
|
||||
rowHeaderWidth: 15,
|
||||
rowHeights: 20,
|
||||
licenseKey: this.hotTableLicenseKey,
|
||||
className: 'htDark'
|
||||
className: 'htDark',
|
||||
theme: 'ht-theme-classic'
|
||||
}
|
||||
}
|
||||
|
||||
// Single place that changes the active tab so the settings signal stays in sync.
|
||||
private setSelectedTab(tab: Tabs) {
|
||||
this.selectedTab = tab
|
||||
this.hotTableSettings.set(this.buildHotSettings())
|
||||
}
|
||||
|
||||
constructor(
|
||||
private eventService: EventService,
|
||||
private licenceService: LicenceService,
|
||||
@@ -190,7 +204,7 @@ export class XLMapComponent implements AfterContentInit, AfterViewInit, OnInit {
|
||||
if (this.fileUploadInputCompList.first) {
|
||||
this.fileUploadInputCompList.first.nativeElement.value = ''
|
||||
}
|
||||
this.selectedTab = Tabs.Rules
|
||||
this.setSelectedTab(Tabs.Rules)
|
||||
this.viewXLMapRules()
|
||||
this.router.navigateByUrl('/home/excel-maps/' + xlmap.id)
|
||||
}
|
||||
@@ -316,7 +330,7 @@ export class XLMapComponent implements AfterContentInit, AfterViewInit, OnInit {
|
||||
this.isLoadingDesc = ''
|
||||
this.status = Status.ReadyToUpload
|
||||
this.xlData = []
|
||||
this.selectedTab = Tabs.Rules
|
||||
this.setSelectedTab(Tabs.Rules)
|
||||
this.filename = ''
|
||||
this.uploader.queue = []
|
||||
if (this.fileUploadInputCompList.first) {
|
||||
@@ -466,7 +480,7 @@ export class XLMapComponent implements AfterContentInit, AfterViewInit, OnInit {
|
||||
this.isLoadingDesc = ''
|
||||
|
||||
this.xlData = extractedData
|
||||
this.selectedTab = Tabs.Data
|
||||
this.setSelectedTab(Tabs.Data)
|
||||
}
|
||||
|
||||
async viewXLMapRules() {
|
||||
@@ -509,6 +523,7 @@ export class XLMapComponent implements AfterContentInit, AfterViewInit, OnInit {
|
||||
this.licenceService.hot_license_key.subscribe(
|
||||
(hot_license_key: string | undefined) => {
|
||||
this.hotTableLicenseKey = hot_license_key
|
||||
this.hotTableSettings.set(this.buildHotSettings())
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user