Compare commits

..
Author SHA1 Message Date
sead 0b7b51046b fix(ci): update lighthouse appLoc
Build / Build-and-ng-test (pull_request) Successful in 3m43s
Build / Build-and-test-development (pull_request) Successful in 9m43s
Lighthouse Checks / lighthouse (pull_request) Failing after 17m18s
2026-06-16 23:51:10 +02:00
63 changed files with 2075 additions and 12101 deletions
-606
View File
@@ -1,606 +0,0 @@
---
name: handsontable
description: >
Use this skill whenever the user asks about Handsontable — a JavaScript data grid component with
spreadsheet-like UX. Triggers include: mentions of "handsontable", "HotTable", "data grid",
"@handsontable/react-wrapper", spreadsheet component in React/Angular/Vue, HyperFormula formulas
inside a grid, cell types (dropdown, checkbox, date, numeric) in a grid context, or questions
about Handsontable theming, plugins, hooks, configuration options, or column/row features. Also
trigger when the user wants to add an editable spreadsheet-like table to a web app, or asks about
copy/paste, sorting, filtering, frozen rows/columns, merged cells, or context menus in a grid.
This skill covers Handsontable used with any framework (React, Angular, Vue 3, vanilla JS) and
HyperFormula as the Formulas plugin engine inside Handsontable. Do NOT use this skill for
headless/standalone HyperFormula usage without Handsontable — that is covered by the separate
"hyperformula" skill.
---
# Handsontable
Handsontable is a JavaScript **data grid component** (not a full spreadsheet application) that
brings spreadsheet-like UX to web apps: cell editing, copy/paste, sorting, filtering, formulas,
keyboard navigation, context menus, merged cells, frozen rows/columns, conditional formatting, data
validation, pagination, and 400+ built-in formulas via HyperFormula.
- **Latest version:** 17.1.0 (May 2026)
- **Frameworks:** Vanilla JS/TS, React (`@handsontable/react-wrapper`), Angular (`@handsontable/angular-wrapper`), Vue 3 (`@handsontable/vue3`)
- **React wrapper requires:** React 18+
- **License:** Dual — free for non-commercial use (`licenseKey: 'non-commercial-and-evaluation'`), paid for commercial. Per-developer annual license, offline validation (no server connection). Tiers: Hobby (free, non-commercial), Trial (free 45 days), Standard (from $999/yr), Priority (from $1,299/yr), Enterprise (custom). See [Pricing](https://handsontable.com/pricing).
Always check `references/docs-map.md` (in this skill folder) for the full organized link directory
when you need to point the user to specific documentation or need to look up more info.
### What this skill does NOT cover
- **Standalone HyperFormula** (headless formula engine without a grid UI) — use the "hyperformula" skill instead.
- **Other data grid libraries** (AG Grid, TanStack Table, etc.).
- **Full spreadsheet applications** — Handsontable is a component you embed, not a standalone app like Google Sheets.
---
## React Quick Start (Recommended)
### Install
```bash
npm install handsontable @handsontable/react-wrapper
```
### Minimal working example
```jsx
import { HotTable } from '@handsontable/react-wrapper';
import { registerAllModules } from 'handsontable/registry';
import 'handsontable/styles/handsontable.min.css';
import 'handsontable/styles/ht-theme-main.min.css';
registerAllModules();
const MyGrid = () => (
<HotTable
themeName="ht-theme-main"
data={[
['', 'Tesla', 'Volvo', 'Toyota', 'Ford'],
['2019', 10, 11, 12, 13],
['2020', 20, 11, 14, 13],
['2021', 30, 15, 12, 13],
]}
colHeaders={true}
rowHeaders={true}
height="auto"
autoWrapRow={true}
autoWrapCol={true}
licenseKey="non-commercial-and-evaluation"
/>
);
export default MyGrid;
```
Key points:
- `registerAllModules()` registers every built-in plugin. To reduce bundle size, import only what
you need — see [Modules guide](https://handsontable.com/docs/react-data-grid/modules/).
- `height` is required (or the grid won't render). Use `"auto"`, a pixel number, or a CSS string.
- All Handsontable configuration options are passed as props on `<HotTable>`.
- Full installation guide: https://handsontable.com/docs/react-data-grid/installation/
---
## Vanilla JS Quick Start
### Install
```bash
npm install handsontable
```
### CDN (jsDelivr)
```html
<script src="https://cdn.jsdelivr.net/npm/handsontable/dist/handsontable.full.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/handsontable/styles/handsontable.min.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/handsontable/styles/ht-theme-main.min.css" />
```
To pin a specific version, add `@17.1` after `handsontable` in the URL (e.g.,
`handsontable@17.1/dist/handsontable.full.min.js`).
### Minimal working example
```js
import Handsontable from 'handsontable';
import 'handsontable/styles/handsontable.min.css';
import 'handsontable/styles/ht-theme-main.min.css';
const container = document.getElementById('grid');
new Handsontable(container, {
data: [
['', 'Tesla', 'Volvo', 'Toyota', 'Ford'],
['2019', 10, 11, 12, 13],
['2020', 20, 11, 14, 13],
['2021', 30, 15, 12, 13],
],
colHeaders: true,
rowHeaders: true,
height: 'auto',
themeName: 'ht-theme-main',
autoWrapRow: true,
autoWrapCol: true,
licenseKey: 'non-commercial-and-evaluation',
});
```
Full JS docs: https://handsontable.com/docs/javascript-data-grid/installation/
---
## Other Frameworks
### Angular (v1719)
```bash
npm install handsontable @handsontable/angular-wrapper
```
The Angular wrapper was modernized in Handsontable v17.1 to align with Angular 1719, simplifying setup and reducing dependencies. Earlier Angular versions are no longer the target — upgrade Angular first if you're below v17.
Docs: https://handsontable.com/docs/angular-data-grid/installation/
### Vue 3
```bash
npm install handsontable @handsontable/vue3
```
Docs: https://handsontable.com/docs/react-data-grid/vue3-installation/
All framework wrappers share the same version number as the core library and expose the same
configuration options. The React examples in this skill translate directly — just use the
framework's component syntax. Note: Vue 2 (`@handsontable/vue`) is deprecated — use Vue 3.
---
## Theming: Light / Dark / Auto
Handsontable v15+ includes a built-in theme system. Three themes ship out of the box: **main**
(default, spreadsheet-like), **horizon** (clean, analytics-focused), **classic** (legacy
replacement).
### CSS file approach (simplest)
Import the base stylesheet plus a theme:
```js
import 'handsontable/styles/handsontable.min.css';
import 'handsontable/styles/ht-theme-main.min.css';
```
Then set the theme on the grid:
```jsx
<HotTable themeName="ht-theme-main" /* ... */ />
```
**Dark mode:**
- System preference (auto): `themeName="ht-theme-main-dark-auto"`
- Forced dark: `themeName="ht-theme-main-dark"`
Replace `main` with `horizon` or `classic` for other themes.
### Theme API approach (runtime switching)
```js
import { mainTheme, registerTheme } from 'handsontable/themes';
const theme = registerTheme(mainTheme)
.setColorScheme('auto') // 'light' | 'dark' | 'auto'
.setDensityType('comfortable'); // 'compact' | 'default' | 'comfortable'
```
Then pass it as a prop:
```jsx
<HotTable theme={theme} /* ... */ />
```
### CDN theme files
```html
<!-- Theme CSS (includes light and dark mode support) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/handsontable/styles/ht-theme-main.min.css" />
```
In v17, dark mode is controlled via `themeName` or the Theme API — there are no separate `-dark` or
`-dark-auto` CSS files. Load the base theme CSS above and set the mode at runtime.
For CSS variable customization, the Theme Builder, Figma design system, and 200+ design tokens, see:
- [Themes guide](https://handsontable.com/docs/react-data-grid/themes/)
- [Theme customization](https://handsontable.com/docs/react-data-grid/theme-customization/)
- [Theme Builder tool](https://handsontable.com/theme-builder)
---
## Common Configuration Patterns
All options below are passed as props on `<HotTable>` (React) or in the config object (vanilla JS).
For the full options reference: https://handsontable.com/docs/react-data-grid/api/options/
### Data binding
```jsx
// Array of arrays
data={[['A1', 'B1'], ['A2', 'B2']]}
// Array of objects
data={[{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]}
columns={[{ data: 'id' }, { data: 'name' }]}
```
Docs: https://handsontable.com/docs/react-data-grid/binding-to-data/
### Column & row headers
```jsx
colHeaders={['ID', 'Name', 'Price']} // custom labels, or {true} for A, B, C...
rowHeaders={true} // 1, 2, 3... or pass an array
```
### Column types (cell types)
Set via the `columns` array or `cells` function. Built-in types:
- `text` (default), `numeric`, `date`, `time`, `checkbox`, `select`, `dropdown`,
`autocomplete`, `password`, `handsontable` (nested grid), `multiselect` (v17+)
```jsx
columns={[
{ data: 'name', type: 'text' },
{ data: 'price', type: 'numeric', numericFormat: { pattern: '$0,0.00' } },
{ data: 'active', type: 'checkbox' },
{ data: 'category', type: 'dropdown', source: ['A', 'B', 'C'] },
]}
```
Full cell types reference: https://handsontable.com/docs/react-data-grid/cell-type/
### Sorting & filtering
```jsx
columnSorting={true} // single-column sort
// or
multiColumnSorting={true} // multi-column sort
filters={true} // enable column filters
dropdownMenu={true} // column header menu with filter UI
```
### Frozen rows/columns
```jsx
fixedRowsTop={1}
fixedRowsBottom={1}
fixedColumnsStart={2}
```
### Other common options
```jsx
readOnly={true} // entire grid read-only (or per-cell/column)
contextMenu={true} // right-click menu
mergeCells={[{ row: 0, col: 0, rowspan: 2, colspan: 2 }]}
manualColumnResize={true}
manualRowResize={true}
stretchH="all" // stretch columns to fill width: 'none' | 'last' | 'all'
```
### Per-column config with HotColumn (React)
```jsx
import { HotTable, HotColumn } from '@handsontable/react-wrapper';
<HotTable data={data} licenseKey="non-commercial-and-evaluation">
<HotColumn title="Name" data="name" />
<HotColumn title="Active" data="active" type="checkbox" />
</HotTable>
```
Docs: https://handsontable.com/docs/react-data-grid/hot-column/
---
## HyperFormula Integration (Formulas Plugin)
HyperFormula powers Handsontable's Formulas plugin, providing 400+ spreadsheet functions (SUM,
AVERAGE, IF, VLOOKUP, etc.). This section covers using HyperFormula **inside** Handsontable only.
### Install
```bash
npm install hyperformula
```
> **Note:** Starting in Handsontable v18, HyperFormula will no longer be bundled. Install it
> separately and pass it to the Formulas plugin.
### CDN
```html
<script src="https://cdn.jsdelivr.net/npm/hyperformula/dist/hyperformula.full.min.js"></script>
```
### Simple setup (auto-created instance)
Pass the `HyperFormula` class directly — Handsontable creates an instance automatically:
```jsx
import { HyperFormula } from 'hyperformula';
<HotTable
data={[
['10', '20', '=SUM(A1:B1)'],
['30', '40', '=SUM(A2:B2)'],
]}
formulas={{ engine: HyperFormula }}
colHeaders={true}
licenseKey="non-commercial-and-evaluation"
/>
```
### External instance (for multi-sheet or shared engine)
Create a HyperFormula instance with the `'internal-use-in-handsontable'` license key:
```jsx
import { HyperFormula } from 'hyperformula';
const hfInstance = HyperFormula.buildEmpty({
licenseKey: 'internal-use-in-handsontable',
});
// Sheet 1
<HotTable
data={data1}
formulas={{ engine: hfInstance, sheetName: 'Sheet1' }}
licenseKey="non-commercial-and-evaluation"
/>
// Sheet 2 — shares the same engine, enabling cross-sheet references
<HotTable
data={data2}
formulas={{ engine: hfInstance, sheetName: 'Sheet2' }}
licenseKey="non-commercial-and-evaluation"
/>
```
Cross-sheet formula example: `=SUM(Sheet1!A:A)`
### Named expressions
```jsx
formulas={{
engine: HyperFormula,
namedExpressions: [
{ name: 'TAX_RATE', expression: 0.21 },
],
}}
```
Use in cells: `=A1 * TAX_RATE`
### Listening for formula changes
```jsx
<HotTable
formulas={{ engine: HyperFormula }}
afterFormulasValuesUpdate={(changes) => {
changes.forEach((c) => console.log(c.address, c.newValue));
}}
/>
```
### Known limitations
- Formulas don't work with nested object data.
- Moving rows/columns with formulas requires the ManualRowMove / ManualColumnMove plugins (not IndexMapper).
- `getSourceData()` operates on physical indexes; formulas use visual indexes.
Full guide: https://handsontable.com/docs/react-data-grid/formula-calculation/
HyperFormula functions list: https://hyperformula.handsontable.com/docs/guide/built-in-functions.html
HyperFormula version compatibility table: https://handsontable.com/docs/react-data-grid/formula-calculation/#hyperformula-version-support
---
## Server-side data with DataProvider (v17.1+)
The **DataProvider plugin** (new in v17.1) wires the grid up to a remote data source so rows are fetched, sorted, and mutated server-side instead of held in memory. Use it for datasets too large to load up front, or when the source of truth lives in a backend.
The `dataProvider` option is an object with five required keys: a row-id resolver, a paginated `fetchRows` callback, and three mutation callbacks. For a read-only grid, stub the mutation callbacks with `async () => {}` — they must still be present.
```jsx
<HotTable
colHeaders={['ID', 'Name', 'Email']}
pagination={{ pageSize: 25 }}
columnSorting={true}
emptyDataState={true}
height={360}
licenseKey="non-commercial-and-evaluation"
dataProvider={{
// Required: how to extract a stable id from each row
rowId: 'id',
// Required: paginated fetch. `sort` is null when no column is sorted.
// Second arg carries an AbortSignal — pass it to fetch() so the plugin
// can cancel superseded requests when the user pages/sorts quickly.
async fetchRows({ page, pageSize, sort }, { signal }) {
const params = new URLSearchParams({ page, pageSize });
if (sort) {
// sort = { prop: string, order: 'asc' | 'desc' }
params.set('sortBy', sort.prop);
params.set('order', sort.order);
}
const res = await fetch(`/api/users?${params}`, { signal });
const { rows, totalRows } = await res.json();
return { rows, totalRows };
},
// Required. Stub with async () => {} if your grid is read-only.
// The plugin auto-refetches the current page after each callback resolves.
// See the plugin API ref for the exact payload shapes.
onRowsCreate: async () => {},
onRowsUpdate: async () => {},
onRowsRemove: async () => {},
}}
/>
```
### Loading and error UI
The plugin fires three hooks you can wire up as `<HotTable>` props for loading indicators and error surfaces:
- `beforeDataProviderFetch(params)` — fires before each fetch. `params.skipLoading` is set when the plugin wants to suppress your loading indicator (e.g., during a quick refetch).
- `afterDataProviderFetch(result)` — fires after a successful fetch.
- `afterDataProviderFetchError(error)` — fires when `fetchRows` throws or returns a rejected promise.
### Companion options
The plugin is built to pair with `pagination` (paginates server-side), `columnSorting` (single-column server-side sort), and `emptyDataState` (loading + empty state UI). Enable all three when you use DataProvider.
### Authoritative references
- Guide: https://handsontable.com/docs/react-data-grid/server-side-data-fetching/
- Recipe (REST API): https://handsontable.com/docs/react-data-grid/recipes/data-management/load-data-rest-api/
- Plugin API: https://handsontable.com/docs/react-data-grid/api/data-provider/
## Notifications (v17.1+)
The **Notification plugin** (new in v17.1) shows non-blocking toast notifications anchored to the grid — useful for confirming saves, surfacing validation errors, or signaling background sync state. Enable with `notifications: true` and trigger via `hot.getPlugin('notifications').showMessage(...)`. See the plugin guide for placement, severity levels, and auto-dismiss timing: https://handsontable.com/docs/react-data-grid/notification/
---
## Events & Hooks
Handsontable hooks are passed as props on `<HotTable>`:
```jsx
<HotTable
afterChange={(changes, source) => {
if (source !== 'loadData') {
console.log('Cell changed:', changes);
}
}}
beforeChange={(changes, source) => {
// Return false to cancel the edit
}}
afterSelection={(row, col, row2, col2) => {
console.log('Selected:', row, col, 'to', row2, col2);
}}
/>
```
There are 100+ hooks available. Full reference: https://handsontable.com/docs/react-data-grid/api/hooks/
Guide: https://handsontable.com/docs/react-data-grid/events-and-hooks/
---
## Accessing the Instance (Ref)
Use a ref to call Handsontable's core methods:
```jsx
import { useRef } from 'react';
const MyGrid = () => {
const hotRef = useRef(null);
const handleClick = () => {
const hot = hotRef.current?.hotInstance;
if (hot) {
console.log(hot.getData()); // get all data
hot.selectCell(0, 0); // select a cell
}
};
return (
<>
<HotTable ref={hotRef} /* ...options */ />
<button onClick={handleClick}>Get Data</button>
</>
);
};
```
Core API reference: https://handsontable.com/docs/react-data-grid/api/core/
---
## Performance & Large Datasets
Handsontable virtualizes rendering automatically — only visible rows and columns are in the DOM, so
grids with 10k100k+ rows perform well out of the box. For bulk programmatic updates, wrap mutations
in `batch()` to defer re-rendering until all changes are applied:
```jsx
hotRef.current.hotInstance.batch(() => {
// set many cells, add rows, etc.
});
```
For further tuning (disabling auto-size, reducing plugin overhead), see:
- [Performance guide](https://handsontable.com/docs/react-data-grid/performance/)
- [Batch operations](https://handsontable.com/docs/react-data-grid/batch-operations/)
- [Bundle size optimization](https://handsontable.com/docs/react-data-grid/bundle-size/)
---
## Recipes (v17+)
Handsontable v17 introduced a Recipes section in the docs — ready-made patterns for common use cases
(data validation workflows, dynamic column generation, etc.). Check the recipes index before building
something from scratch: https://handsontable.com/docs/react-data-grid/recipes/
---
## Common Pitfalls
- **Forgetting `height`**: The grid won't render without a `height` prop. Use `"auto"`, a pixel value, or a CSS string.
- **Not filtering `loadData` in `afterChange`**: The `afterChange` hook fires on initial data load with `source === 'loadData'`. Always check the source to avoid infinite loops when syncing changes back to state.
- **Using the old wrapper packages**: v17 removed `@handsontable/react` and `@handsontable/angular`. Use `@handsontable/react-wrapper` and `@handsontable/angular-wrapper`.
- **Using legacy CSS imports**: `handsontable.full.min.css` was removed in v17. Use `handsontable/styles/handsontable.min.css` plus a theme file.
- **Formulas with nested object data**: HyperFormula formulas don't work when `data` is an array of nested objects — use flat objects or arrays of arrays.
- **ExportFile `columnHeaders` renamed**: In v17.1 the ExportFile plugin's `columnHeaders` option was renamed to `colHeaders` to match the table-level option. Update any `exportAsString` / `exportAsBlob` / `downloadFile` calls that pass `columnHeaders: ...`.
---
## Version Awareness
Handsontable docs are versioned. The latest docs live at `/docs/react-data-grid/` (which redirects
to the current version). To link to a specific version, use `/docs/17.0/react-data-grid/`.
When helping a user, check which version they are on — breaking changes between major versions are
common. Point them to the relevant migration guide if they're upgrading.
For the full organized directory of documentation links, read `references/docs-map.md` in this
skill's folder.
### v17.1 changes (latest, May 2026)
- **New plugins:** DataProvider (server-side row loading via `dataProvider` option), Notification (toast notifications).
- **NestedHeaders rowspan:** column headers can now span multiple header rows.
- **ExportFile:** XLSX export added; the `columnHeaders` option was renamed to `colHeaders` (see Common Pitfalls).
- **Angular wrapper:** modernized for Angular 1719; simpler setup, fewer deps.
- **Touch:** long-press now opens the context menu on touch devices.
- **TypeScript:** `dateFormat` option now accepts `Intl.DateTimeFormatOptions`.
- No removals or deprecations in v17.1.
### v17.0 Breaking Changes
- Removed legacy wrapper packages (`@handsontable/react`, `@handsontable/angular`). Use
`@handsontable/react-wrapper` and `@handsontable/angular-wrapper`.
- Removed legacy CSS (`handsontable.full.min.css`). Use `handsontable/styles/` imports.
- Removed core-js from dependencies.
- Removed the PersistentState plugin.
- Deprecated bundled HyperFormula (will require separate install in v18).
- Deprecated numbro.js, Pikaday, moment.js, DOMPurify — use native alternatives.
Migration guide: https://handsontable.com/docs/react-data-grid/migration-from-16.2-to-17.0/
@@ -1,231 +0,0 @@
# Handsontable Documentation Map
> Last verified: May 2026 · Aligned with Handsontable 17.1.0
Organized link directory for all Handsontable and HyperFormula (in-grid) documentation. Use these
to point users to the right page. Links default to the React docs; replace `react-data-grid` with
`javascript-data-grid`, `angular-data-grid`, or prefix with `vue3-` for other frameworks.
## Getting Started
- Installation: https://handsontable.com/docs/react-data-grid/installation/
- Demo: https://handsontable.com/docs/react-data-grid/demo/
- Binding to data: https://handsontable.com/docs/react-data-grid/binding-to-data/
- Server-side data fetching (DataProvider, v17.1+): https://handsontable.com/docs/react-data-grid/server-side-data-fetching/
- Saving data: https://handsontable.com/docs/react-data-grid/saving-data/
- Configuration options guide: https://handsontable.com/docs/react-data-grid/configuration-options/
- Grid size: https://handsontable.com/docs/react-data-grid/grid-size/
- Instance methods: https://handsontable.com/docs/react-data-grid/instance-methods/
- Events and hooks: https://handsontable.com/docs/react-data-grid/events-and-hooks/
- License key: https://handsontable.com/docs/react-data-grid/license-key/
- Redux integration: https://handsontable.com/docs/react-data-grid/redux/
## Styling & Themes
- Themes guide: https://handsontable.com/docs/react-data-grid/themes/
- Design system (Figma): https://handsontable.com/docs/react-data-grid/handsontable-design-system/
- Theme customization (CSS vars, tokens): https://handsontable.com/docs/react-data-grid/theme-customization/
- Legacy style (pre-v15): https://handsontable.com/docs/react-data-grid/legacy-style/
- Theme Builder tool: https://handsontable.com/theme-builder
- Figma Theme Generator: https://github.com/handsontable/handsontable-figma
## Columns
- HotColumn component (React): https://handsontable.com/docs/react-data-grid/hot-column/
- Column headers: https://handsontable.com/docs/react-data-grid/column-header/
- Column groups (nested headers): https://handsontable.com/docs/react-data-grid/column-groups/
- Column hiding: https://handsontable.com/docs/react-data-grid/column-hiding/
- Column moving: https://handsontable.com/docs/react-data-grid/column-moving/
- Column freezing: https://handsontable.com/docs/react-data-grid/column-freezing/
- Column widths: https://handsontable.com/docs/react-data-grid/column-width/
- Column summary: https://handsontable.com/docs/react-data-grid/column-summary/
- Column virtualization: https://handsontable.com/docs/react-data-grid/column-virtualization/
- Column menu (dropdown): https://handsontable.com/docs/react-data-grid/column-menu/
- Column filter: https://handsontable.com/docs/react-data-grid/column-filter/
## Rows
- Row headers: https://handsontable.com/docs/react-data-grid/row-header/
- Row parent-child (nested rows): https://handsontable.com/docs/react-data-grid/row-parent-child/
- Row hiding: https://handsontable.com/docs/react-data-grid/row-hiding/
- Row moving: https://handsontable.com/docs/react-data-grid/row-moving/
- Row freezing: https://handsontable.com/docs/react-data-grid/row-freezing/
- Row heights: https://handsontable.com/docs/react-data-grid/row-height/
- Row virtualization: https://handsontable.com/docs/react-data-grid/row-virtualization/
- Rows sorting: https://handsontable.com/docs/react-data-grid/rows-sorting/
- Rows pagination: https://handsontable.com/docs/react-data-grid/rows-pagination/
- Row trimming: https://handsontable.com/docs/react-data-grid/row-trimming/
- Row pre-populating: https://handsontable.com/docs/react-data-grid/row-prepopulating/
## Cell Features
- Clipboard (copy/paste): https://handsontable.com/docs/react-data-grid/basic-clipboard/
- Selection: https://handsontable.com/docs/react-data-grid/selection/
- Merge cells: https://handsontable.com/docs/react-data-grid/merge-cells/
- Conditional formatting: https://handsontable.com/docs/react-data-grid/conditional-formatting/
- Text alignment: https://handsontable.com/docs/react-data-grid/text-alignment/
- Disabled cells: https://handsontable.com/docs/react-data-grid/disabled-cells/
- Comments: https://handsontable.com/docs/react-data-grid/comments/
- Autofill values: https://handsontable.com/docs/react-data-grid/autofill-values/
- Formatting cells: https://handsontable.com/docs/react-data-grid/formatting-cells/
## Cell Functions
- Cell functions overview: https://handsontable.com/docs/react-data-grid/cell-function/
- Cell renderer: https://handsontable.com/docs/react-data-grid/cell-renderer/
- Cell editor: https://handsontable.com/docs/react-data-grid/cell-editor/
- Cell validator: https://handsontable.com/docs/react-data-grid/cell-validator/
- Custom cells (v17+): https://handsontable.com/docs/react-data-grid/custom-cells/
## Cell Types
- Cell type overview: https://handsontable.com/docs/react-data-grid/cell-type/
- Numeric: https://handsontable.com/docs/react-data-grid/numeric-cell-type/
- Date: https://handsontable.com/docs/react-data-grid/date-cell-type/
- Time: https://handsontable.com/docs/react-data-grid/time-cell-type/
- Checkbox: https://handsontable.com/docs/react-data-grid/checkbox-cell-type/
- Select: https://handsontable.com/docs/react-data-grid/select-cell-type/
- Dropdown: https://handsontable.com/docs/react-data-grid/dropdown-cell-type/
- Autocomplete: https://handsontable.com/docs/react-data-grid/autocomplete-cell-type/
- MultiSelect (v17+): https://handsontable.com/docs/react-data-grid/multiselect-cell-type/
- Password: https://handsontable.com/docs/react-data-grid/password-cell-type/
- Handsontable (nested grid): https://handsontable.com/docs/react-data-grid/handsontable-cell-type/
## Formulas (HyperFormula in Handsontable)
- Formula calculation guide: https://handsontable.com/docs/react-data-grid/formula-calculation/
- Formulas plugin API: https://handsontable.com/docs/react-data-grid/api/formulas/
- HyperFormula built-in functions list: https://hyperformula.handsontable.com/docs/guide/built-in-functions.html
- HyperFormula configuration options: https://hyperformula.handsontable.com/docs/guide/configuration-options.html
- HyperFormula custom functions: https://hyperformula.handsontable.com/docs/guide/custom-functions.html
- HyperFormula named expressions: https://hyperformula.handsontable.com/docs/guide/cell-references.html#relative-references
- HyperFormula license key: https://hyperformula.handsontable.com/docs/guide/license-key.html
## Navigation
- Keyboard shortcuts: https://handsontable.com/docs/react-data-grid/keyboard-shortcuts/
- Custom shortcuts: https://handsontable.com/docs/react-data-grid/custom-shortcuts/
- Focus scopes: https://handsontable.com/docs/react-data-grid/focus-scopes/
- Searching values: https://handsontable.com/docs/react-data-grid/searching-values/
## Accessibility
- Accessibility guide: https://handsontable.com/docs/react-data-grid/accessibility/
## Menus & Accessories
- Context menu: https://handsontable.com/docs/react-data-grid/context-menu/
- Undo and redo: https://handsontable.com/docs/react-data-grid/undo-redo/
- Icon pack: https://handsontable.com/docs/react-data-grid/icon-pack/
- Export to CSV: https://handsontable.com/docs/react-data-grid/export-to-csv/
- Export to Excel/XLSX (v17.1+): https://handsontable.com/docs/react-data-grid/export-to-excel/
- Notification (v17.1+): https://handsontable.com/docs/react-data-grid/notification/
- Empty data state: https://handsontable.com/docs/react-data-grid/empty-data-state/
## Dialog & Loading
- Dialog: https://handsontable.com/docs/react-data-grid/dialog/
- Loading indicator: https://handsontable.com/docs/react-data-grid/loading/
## Internationalization
- Language: https://handsontable.com/docs/react-data-grid/language/
- Locale: https://handsontable.com/docs/react-data-grid/locale/
- Layout direction (RTL/LTR): https://handsontable.com/docs/react-data-grid/layout-direction/
- IME support: https://handsontable.com/docs/react-data-grid/ime-support/
## Tools & Building
- Modules (tree-shaking): https://handsontable.com/docs/react-data-grid/modules/
- Custom plugins: https://handsontable.com/docs/react-data-grid/custom-plugins/
- Custom builds: https://handsontable.com/docs/react-data-grid/custom-builds/
- Testing: https://handsontable.com/docs/react-data-grid/testing/
## Optimization
- Batch operations: https://handsontable.com/docs/react-data-grid/batch-operations/
- Performance: https://handsontable.com/docs/react-data-grid/performance/
- Bundle size: https://handsontable.com/docs/react-data-grid/bundle-size/
## Security
- Security guide: https://handsontable.com/docs/react-data-grid/security/
## API Reference
- API overview: https://handsontable.com/docs/react-data-grid/api/
- Core (instance methods): https://handsontable.com/docs/react-data-grid/api/core/
- Hooks (all events): https://handsontable.com/docs/react-data-grid/api/hooks/
- Configuration options (all props): https://handsontable.com/docs/react-data-grid/api/options/
- Plugins index: https://handsontable.com/docs/react-data-grid/api/plugins/
### Individual Plugin APIs
- AutoColumnSize: https://handsontable.com/docs/react-data-grid/api/auto-column-size/
- AutoRowSize: https://handsontable.com/docs/react-data-grid/api/auto-row-size/
- Autofill: https://handsontable.com/docs/react-data-grid/api/autofill/
- BindRowsWithHeaders: https://handsontable.com/docs/react-data-grid/api/bind-rows-with-headers/
- CollapsibleColumns: https://handsontable.com/docs/react-data-grid/api/collapsible-columns/
- ColumnSorting: https://handsontable.com/docs/react-data-grid/api/column-sorting/
- ColumnSummary: https://handsontable.com/docs/react-data-grid/api/column-summary/
- Comments: https://handsontable.com/docs/react-data-grid/api/comments/
- ContextMenu: https://handsontable.com/docs/react-data-grid/api/context-menu/
- CopyPaste: https://handsontable.com/docs/react-data-grid/api/copy-paste/
- CustomBorders: https://handsontable.com/docs/react-data-grid/api/custom-borders/
- DataProvider (v17.1+): https://handsontable.com/docs/react-data-grid/api/data-provider/
- Dialog: https://handsontable.com/docs/react-data-grid/api/dialog/
- DragToScroll: https://handsontable.com/docs/react-data-grid/api/drag-to-scroll/
- DropdownMenu: https://handsontable.com/docs/react-data-grid/api/dropdown-menu/
- EmptyDataState: https://handsontable.com/docs/react-data-grid/api/empty-data-state/
- ExportFile: https://handsontable.com/docs/react-data-grid/api/export-file/
- Filters: https://handsontable.com/docs/react-data-grid/api/filters/
- Formulas: https://handsontable.com/docs/react-data-grid/api/formulas/
- HiddenColumns: https://handsontable.com/docs/react-data-grid/api/hidden-columns/
- HiddenRows: https://handsontable.com/docs/react-data-grid/api/hidden-rows/
- Loading: https://handsontable.com/docs/react-data-grid/api/loading/
- ManualColumnFreeze: https://handsontable.com/docs/react-data-grid/api/manual-column-freeze/
- ManualColumnMove: https://handsontable.com/docs/react-data-grid/api/manual-column-move/
- ManualColumnResize: https://handsontable.com/docs/react-data-grid/api/manual-column-resize/
- ManualRowMove: https://handsontable.com/docs/react-data-grid/api/manual-row-move/
- ManualRowResize: https://handsontable.com/docs/react-data-grid/api/manual-row-resize/
- MergeCells: https://handsontable.com/docs/react-data-grid/api/merge-cells/
- MultiColumnSorting: https://handsontable.com/docs/react-data-grid/api/multi-column-sorting/
- NestedHeaders: https://handsontable.com/docs/react-data-grid/api/nested-headers/
- NestedRows: https://handsontable.com/docs/react-data-grid/api/nested-rows/
- Notification (v17.1+): https://handsontable.com/docs/react-data-grid/api/notification/
- Pagination: https://handsontable.com/docs/react-data-grid/api/pagination/
- Search: https://handsontable.com/docs/react-data-grid/api/search/
- StretchColumns: https://handsontable.com/docs/react-data-grid/api/stretch-columns/
- TrimRows: https://handsontable.com/docs/react-data-grid/api/trim-rows/
- UndoRedo: https://handsontable.com/docs/react-data-grid/api/undo-redo/
## Recipes (new in v17)
- Recipes index: https://handsontable.com/docs/react-data-grid/recipes/
- Load data from a REST API (DataProvider, v17.1+): https://handsontable.com/docs/react-data-grid/recipes/data-management/load-data-rest-api/
## Upgrade & Migration
- Changelog: https://handsontable.com/docs/react-data-grid/changelog/
- Versioning policy: https://handsontable.com/docs/react-data-grid/versioning-policy/
- Deprecation policy: https://handsontable.com/docs/react-data-grid/deprecation-policy/
- Long Term Support (LTS): https://handsontable.com/docs/react-data-grid/long-term-support/
- 16.2 → 17.0 migration: https://handsontable.com/docs/react-data-grid/migration-from-16.2-to-17.0/
- 16.0 → 16.1 migration: https://handsontable.com/docs/react-data-grid/migration-from-16.0-to-16.1/
- 15.3 → 16.0 migration: https://handsontable.com/docs/react-data-grid/migration-from-15.3-to-16.0/
- 14.6 → 15.0 migration: https://handsontable.com/docs/react-data-grid/migration-from-14.6-to-15.0/
## Other Framework Installation
- JavaScript: https://handsontable.com/docs/javascript-data-grid/installation/
- Angular: https://handsontable.com/docs/angular-data-grid/installation/
- Vue 3: https://handsontable.com/docs/react-data-grid/vue3-installation/
## SSR Examples (CodeSandbox)
- Next.js: https://codesandbox.io/p/sandbox/kwnjph?file=https://handsontable.com/codesandbox-vm?example-dir=next.js&handsontable-version=17.1preview=true
- Astro: https://codesandbox.io/p/sandbox/gnqcwn?file=https://handsontable.com/codesandbox-vm?example-dir=astro&handsontable-version=17.1preview=true
- Remix: https://codesandbox.io/p/sandbox/njcjlq?file=https://handsontable.com/codesandbox-vm?example-dir=remix&handsontable-version=17.1preview=true
- Nuxt: https://codesandbox.io/p/sandbox/r7qsjc?file=https://handsontable.com/codesandbox-vm?example-dir=nuxt&handsontable-version=17.1preview=true
## CDN Links (jsDelivr) — latest
- JS (full bundle): https://cdn.jsdelivr.net/npm/handsontable/dist/handsontable.full.min.js
- Base CSS: https://cdn.jsdelivr.net/npm/handsontable/styles/handsontable.min.css
- Theme Main: https://cdn.jsdelivr.net/npm/handsontable/styles/ht-theme-main.min.css
- Theme Horizon: https://cdn.jsdelivr.net/npm/handsontable/styles/ht-theme-horizon.min.css
- Theme Classic: https://cdn.jsdelivr.net/npm/handsontable/styles/ht-theme-classic.min.css
- React wrapper: https://cdn.jsdelivr.net/npm/@handsontable/react-wrapper/dist/react-handsontable.min.js
- HyperFormula: https://cdn.jsdelivr.net/npm/hyperformula/dist/hyperformula.full.min.js
Pin versions by adding `@17.1` after the package name (e.g., `handsontable@17.1`).
## Package Registries
- npm: https://www.npmjs.com/package/handsontable
- NuGet: https://www.nuget.org/packages/handsontable
## Community & Support
- Developer Forum: https://forum.handsontable.com/
- GitHub Issues: https://github.com/handsontable/handsontable/issues
- Stack Overflow: https://stackoverflow.com/tags/handsontable
- Commercial support: support@handsontable.com
- Contact form: https://handsontable.com/contact
- Blog / Release Notes: https://handsontable.com/blog/categories/release-notes
- Download page: https://handsontable.com/download
-85
View File
@@ -1,85 +0,0 @@
# @handsontable/hyperformula-skill
An agent skill for [**HyperFormula**](https://hyperformula.handsontable.com) — the headless, open-source TypeScript spreadsheet calculation engine. It gives any AI coding agent deep, task-oriented knowledge of HyperFormula so the agent can help you integrate, configure, and debug the engine faster.
This package is the npm distribution of the `hyperformula` skill maintained in [`handsontable/handsontable-skills`](https://github.com/handsontable/handsontable-skills). The package version tracks the HyperFormula product version the skill targets (e.g. `3.3.0` targets HyperFormula 3.3.0).
## What's in the package
A self-contained skill — plain markdown, no runtime dependencies:
```
SKILL.md ← entry point: concepts, routing, and a docs map (YAML frontmatter + body)
references/ ← task-oriented deep-dives the agent reads on demand
```
`SKILL.md` follows the [Agent Skills](https://claude.com/blog/skills) layout (a YAML frontmatter block with `name`/`description`, followed by markdown instructions). Any agent that can load a skill — or simply read a folder of markdown instructions — can use it.
## Install
```bash
npm install @handsontable/hyperformula-skill
```
The installed package lives at `node_modules/@handsontable/hyperformula-skill`. Resolve that path programmatically with:
```bash
node -p "require.resolve('@handsontable/hyperformula-skill/SKILL.md')"
```
Then make the skill available to your agent using whichever of the following matches it:
### Claude Code
Copy the package into a skills directory. User scope (available in every project):
```bash
cp -r node_modules/@handsontable/hyperformula-skill ~/.claude/skills/hyperformula
```
Or project scope, from your project root:
```bash
cp -r node_modules/@handsontable/hyperformula-skill .claude/skills/hyperformula
```
### Claude API
Upload the package contents (`SKILL.md` + `references/`) to the [Skills API](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview).
### OpenAI Codex
Codex discovers [skills](https://developers.openai.com/codex/skills) as folders containing a `SKILL.md`, under `~/.agents/skills` (available in every repo) or `.agents/skills` in a repo (checked in for your team). Copy the package into one of those locations:
```bash
# User scope — available in every repo
cp -r node_modules/@handsontable/hyperformula-skill ~/.agents/skills/hyperformula
# Repo scope — from your project root
cp -r node_modules/@handsontable/hyperformula-skill .agents/skills/hyperformula
```
Codex also follows symlinked skill folders, so you can instead link the installed package and let `npm install` keep it current:
```bash
ln -s "$PWD/node_modules/@handsontable/hyperformula-skill" ~/.agents/skills/hyperformula
```
### Any other agent
Most coding agents support a skills, rules, or knowledge directory. Copy (or symlink) the package folder into it, for example:
```bash
# Replace <agent-skills-dir> with your agent's skills/knowledge directory
cp -r node_modules/@handsontable/hyperformula-skill <agent-skills-dir>/hyperformula
```
If your agent reads a single instruction file rather than a folder, point it at `SKILL.md`; it links the files under `references/` so the agent can pull them in as needed. For RAG / vector-store setups, ingest `SKILL.md` together with every file under `references/` (the repository also publishes a pre-flattened `hyperformula-rag.md` for this case).
## Other distribution formats
The same skill source is also shipped as a drag-and-drop `.zip` (Cowork / Claude.ai web), a flattened `-rag.md` doc (RAG / vector stores), and a Claude Code plugin marketplace entry. See the [repository README](https://github.com/handsontable/handsontable-skills#readme) for details.
## License
MIT — see the [repository](https://github.com/handsontable/handsontable-skills/blob/main/LICENSE.txt). HyperFormula itself is separately licensed (GPLv3 or commercial); see the [licensing guide](https://hyperformula.handsontable.com/docs/guide/licensing.html).
-134
View File
@@ -1,134 +0,0 @@
---
name: hyperformula
description: >
Use this skill when the user works with HyperFormula (HF) — a headless,
open-source TypeScript spreadsheet calculation engine. Use it when the user:
mentions HyperFormula or HF; wants to integrate Excel-like formulas into a
JS/TS app; evaluate formulas programmatically in a browser or Node.js;
simulate spreadsheet or Excel calculation behavior server-side; run
calculations over data parsed from XLSX files (e.g. via SheetJS) in code;
build a pricing engine, financial model, or what-if analysis in code; define
custom spreadsheet functions or named expressions outside of a UI context. Do
NOT use this skill for: general Excel questions unrelated to HF; general
Google Sheets questions; generic spreadsheet formula-syntax questions outside
an HF context; formulas used inside Handsontable (use the "handsontable"
skill instead).
---
# HyperFormula
HyperFormula is a **headless, open-source TypeScript spreadsheet calculation engine** for embedding spreadsheet logic in any JavaScript/TypeScript application (browser or Node.js). ~420 built-in functions, dependency graph, undo/redo, i18n (17 languages). Dual-licensed: GPLv3 or commercial.
- Docs: https://hyperformula.handsontable.com/
- npm: https://www.npmjs.com/package/hyperformula
- GitHub: https://github.com/handsontable/hyperformula
## Where to go next
Task-oriented references in `references/` (open the one that matches what the user is doing):
- [`getting-started.md`](references/getting-started.md) — how to install, create an instance (`buildFromArray` / `buildFromSheets` / `buildEmpty`), and work with cell addresses. Open this for onboarding questions and first-run errors.
- [`api-quickref.md`](references/api-quickref.md) — runnable examples for CRUD, rows/columns, sheets, exporting data, batching, named expressions, events, undo/redo, clipboard. Open this when implementing or debugging HF API calls.
- [`custom-functions.md`](references/custom-functions.md) — extending HyperFormula via `FunctionPlugin`: minimal plugin, argument types, volatile functions, range arguments, returning arrays, error handling, aliases, localized names. Open this when adding or debugging custom spreadsheet functions.
- [`configuration.md`](references/configuration.md) — `ConfigParams` options, Excel-compatibility preset, Google-Sheets preset, and config-related pitfalls (separator collisions, Node ICU, `precisionRounding` change in v3). Open this when tuning behavior or diagnosing locale issues.
- [`vue3.md`](references/vue3.md) — Vue 3 integration: `markRaw` requirement, Composition API, Pinia/Vuex stores, `destroy()` on unmount. Open this when embedding HF in a Vue 3 app.
- [`error-handling.md`](references/error-handling.md) — inspecting `CellError`, `ErrorType` enum, `getCellValueDetailedType`, common error causes (`#NAME?`, `#CYCLE!`, `#REF!`, …). Open this when a cell returns an error or you need to branch on result type.
- [`general-pitfalls.md`](references/general-pitfalls.md) — cross-cutting gotchas: `destroy()` lifecycle, forcing literal strings, Excel-parity caveats, hard limits. Open this when results look wrong or memory grows unboundedly.
Below this section is the **Documentation map** — the canonical directory of links to the official HyperFormula docs. Use it when pointing the user to authoritative material.
## Documentation map
All links resolve to `hyperformula.handsontable.com` unless noted.
### Introduction
- Welcome / Homepage: https://hyperformula.handsontable.com/
- Demo: https://hyperformula.handsontable.com/docs/guide/demo.html
### Overview
- Quality (test coverage, CI): https://hyperformula.handsontable.com/docs/guide/quality.html
- Supported browsers: https://hyperformula.handsontable.com/docs/guide/supported-browsers.html
- Dependencies: https://hyperformula.handsontable.com/docs/guide/dependencies.html
- Licensing (GPLv3 vs commercial): https://hyperformula.handsontable.com/docs/guide/licensing.html
- Support: https://hyperformula.handsontable.com/docs/guide/support.html
### Getting Started
- Client-side installation: https://hyperformula.handsontable.com/docs/guide/client-side-installation.html
- Server-side installation (Node.js): https://hyperformula.handsontable.com/docs/guide/server-side-installation.html
- Basic usage: https://hyperformula.handsontable.com/docs/guide/basic-usage.html
- Advanced usage: https://hyperformula.handsontable.com/docs/guide/advanced-usage.html
- Configuration options guide: https://hyperformula.handsontable.com/docs/guide/configuration-options.html
- License key setup: https://hyperformula.handsontable.com/docs/guide/license-key.html
### Framework Integration
- React: https://hyperformula.handsontable.com/docs/guide/integration-with-react.html
- Vue: https://hyperformula.handsontable.com/docs/guide/integration-with-vue.html
- Angular: https://hyperformula.handsontable.com/docs/guide/integration-with-angular.html
- Svelte: https://hyperformula.handsontable.com/docs/guide/integration-with-svelte.html
### Data Operations
- Basic operations (CRUD): https://hyperformula.handsontable.com/docs/guide/basic-operations.html
- Batch operations: https://hyperformula.handsontable.com/docs/guide/batch-operations.html
- Clipboard operations: https://hyperformula.handsontable.com/docs/guide/clipboard-operations.html
- Undo-redo: https://hyperformula.handsontable.com/docs/guide/undo-redo.html
- Sorting data: https://hyperformula.handsontable.com/docs/guide/sorting-data.html
### Formulas
- Specifications and limits: https://hyperformula.handsontable.com/docs/guide/specifications-and-limits.html
- Cell references (relative, absolute, mixed, cross-sheet): https://hyperformula.handsontable.com/docs/guide/cell-references.html
- Types of values: https://hyperformula.handsontable.com/docs/guide/types-of-values.html
- Types of errors: https://hyperformula.handsontable.com/docs/guide/types-of-errors.html
- Types of operators: https://hyperformula.handsontable.com/docs/guide/types-of-operators.html
- Order of precedence: https://hyperformula.handsontable.com/docs/guide/order-of-precendece.html
- Built-in functions (full list, ~400): https://hyperformula.handsontable.com/docs/guide/built-in-functions.html
- Volatile functions: https://hyperformula.handsontable.com/docs/guide/volatile-functions.html
- Named expressions: https://hyperformula.handsontable.com/docs/guide/named-expressions.html
- Array formulas / ARRAYFORMULA: https://hyperformula.handsontable.com/docs/guide/arrays.html
### Internationalization
- i18n features (17 languages): https://hyperformula.handsontable.com/docs/guide/i18n-features.html
- Localizing function names: https://hyperformula.handsontable.com/docs/guide/localizing-functions.html
- Date and time handling: https://hyperformula.handsontable.com/docs/guide/date-and-time-handling.html
### Compatibility
- Microsoft Excel compatibility: https://hyperformula.handsontable.com/docs/guide/compatibility-with-microsoft-excel.html
- Google Sheets compatibility: https://hyperformula.handsontable.com/docs/guide/compatibility-with-google-sheets.html
- Runtime differences (Excel & Sheets): https://hyperformula.handsontable.com/docs/guide/list-of-differences.html
### Advanced Topics
- Key concepts (AST, dependency graph, evaluation): https://hyperformula.handsontable.com/docs/guide/key-concepts.html
- Dependency graph: https://hyperformula.handsontable.com/docs/guide/dependency-graph.html
- Building & testing from source: https://hyperformula.handsontable.com/docs/guide/building.html
- Custom functions (FunctionPlugin): https://hyperformula.handsontable.com/docs/guide/custom-functions.html
- Performance: https://hyperformula.handsontable.com/docs/guide/performance.html
- Known limitations: https://hyperformula.handsontable.com/docs/guide/known-limitations.html
- File import: https://hyperformula.handsontable.com/docs/guide/file-import.html
### Upgrade & Migration
- Release notes / Changelog: https://hyperformula.handsontable.com/docs/guide/release-notes.html
- 0.6 → 1.0 migration: https://hyperformula.handsontable.com/docs/guide/migration-from-0.6-to-1.0.html
- 1.x → 2.0 migration: https://hyperformula.handsontable.com/docs/guide/migration-from-1.x-to-2.0.html
- 2.x → 3.0 migration: https://hyperformula.handsontable.com/docs/guide/migration-from-2.x-to-3.0.html
### API Reference
- API overview: https://hyperformula.handsontable.com/docs/api/
- HyperFormula class (all methods): https://hyperformula.handsontable.com/docs/api/classes/hyperformula.html
- ConfigParams interface (all options): https://hyperformula.handsontable.com/docs/api/interfaces/configparams.html
- Listeners interface (all events): https://hyperformula.handsontable.com/docs/api/interfaces/listeners.html
### npm
- npm page: https://www.npmjs.com/package/hyperformula
### Community & Support
- GitHub repo: https://github.com/handsontable/hyperformula
- GitHub Issues: https://github.com/handsontable/hyperformula/issues
- GitHub Discussions: https://github.com/handsontable/hyperformula/discussions
- Developer Forum: https://forum.handsontable.com/
- Commercial support: support@handsontable.com
- Sales (commercial license): sales@handsontable.com
- Contact form: https://handsontable.com/contact
---
> **Last updated: 2026-05-20 · Aligned with HyperFormula 3.3.0.**
> If the user is on a newer release, confirm API shape against the latest docs (see the **Release notes** link above) before relying on this file.
-34
View File
@@ -1,34 +0,0 @@
{
"name": "@handsontable/hyperformula-skill",
"version": "3.3.0",
"description": "Agent skill for HyperFormula — the headless, open-source TypeScript spreadsheet calculation engine. Teaches AI coding agents how to integrate, configure, and debug HyperFormula.",
"license": "MIT",
"author": "Handsontable <hello@handsontable.com> (https://handsontable.com)",
"homepage": "https://github.com/handsontable/handsontable-skills#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/handsontable/handsontable-skills.git",
"directory": "skills/hyperformula"
},
"bugs": {
"url": "https://github.com/handsontable/handsontable-skills/issues"
},
"keywords": [
"agent-skill",
"claude-skill",
"claude",
"anthropic",
"ai",
"hyperformula",
"spreadsheet",
"formula-engine",
"handsontable"
],
"files": [
"SKILL.md",
"references/"
],
"publishConfig": {
"access": "public"
}
}
@@ -1,147 +0,0 @@
# API Quick Reference
Runnable examples for the most-used HyperFormula APIs. Authoritative docs:
- Basic operations: https://hyperformula.handsontable.com/docs/guide/basic-operations.html
- Batch operations: https://hyperformula.handsontable.com/docs/guide/batch-operations.html
- Named expressions: https://hyperformula.handsontable.com/docs/guide/named-expressions.html
- Clipboard operations: https://hyperformula.handsontable.com/docs/guide/clipboard-operations.html
- Undo-redo: https://hyperformula.handsontable.com/docs/guide/undo-redo.html
- Sorting data: https://hyperformula.handsontable.com/docs/guide/sorting-data.html
- HyperFormula class (all methods): https://hyperformula.handsontable.com/docs/api/classes/hyperformula.html
- Listeners (all events): https://hyperformula.handsontable.com/docs/api/interfaces/listeners.html
## CRUD
```ts
// Write: string, number, or formula
hf.setCellContents({ sheet: 0, col: 0, row: 0 }, 'Hello');
hf.setCellContents({ sheet: 0, col: 1, row: 0 }, '=A1 & " World"');
// Read
hf.getCellValue({ sheet: 0, col: 1, row: 0 }); // computed result
hf.getCellFormula({ sheet: 0, col: 1, row: 0 }); // '=A1 & " World"'
hf.getCellType({ sheet: 0, col: 0, row: 0 }); // CellType.FORMULA | VALUE | EMPTY
```
## Rows and columns
Second arg is `[startIndex, count]`, not a list of indexes.
```ts
hf.addRows(sheetId, [rowIndex, numberOfRows]);
hf.removeRows(sheetId, [rowIndex, numberOfRows]);
hf.addColumns(sheetId, [colIndex, numberOfColumns]);
hf.removeColumns(sheetId, [colIndex, numberOfColumns]);
```
## Sheets
```ts
hf.addSheet('NewSheet');
hf.removeSheet(sheetId);
hf.renameSheet(sheetId, 'BetterName');
hf.countSheets();
hf.getSheetName(sheetId);
hf.getSheetId('SheetName');
hf.getSheetDimensions(sheetId); // → { width, height }
```
## Export data
```ts
hf.getSheetValues(sheetId); // computed values as 2D array
hf.getSheetSerialized(sheetId); // formulas/raw values as 2D array
hf.getAllSheetsValues(); // { SheetName: values[][] }
hf.getAllSheetsSerialized(); // { SheetName: formulas[][] }
```
## Batch operations
Every `setCellContents` call triggers a full dependency-graph recalculation. Always batch when writing more than one cell.
```ts
// Preferred: batch() — returns ExportedChange[]
const changes = hf.batch(() => {
hf.setCellContents({ sheet: 0, col: 0, row: 0 }, '100');
hf.setCellContents({ sheet: 0, col: 0, row: 1 }, '200');
hf.addRows(0, [2, 1]);
});
// `changes` contains all cells that were recalculated.
// For bulk imports (value writes only):
hf.suspendEvaluation();
// ... hundreds of setCellContents calls ...
hf.resumeEvaluation(); // single recalc
// PITFALL: structural ops (addRows/moveRows/...) during suspendEvaluation
// have degraded performance. Use batch() when mixing structural + value writes.
```
## Sorting
`setRowOrder` takes a **permutation array**, not a comparator. Compute the new index sequence externally, then pass it in.
```ts
// Sort rows by the value in column 0
const rowCount = hf.getSheetDimensions(0).height;
const indexes = Array.from({ length: rowCount }, (_, i) => i);
indexes.sort((a, b) => {
const va = hf.getCellValue({ sheet: 0, col: 0, row: a }) as number;
const vb = hf.getCellValue({ sheet: 0, col: 0, row: b }) as number;
return va - vb;
});
hf.setRowOrder(0, indexes); // e.g. [2, 0, 1, 3]
```
## Named expressions
Reusable names for values or formulas. Can be global or scoped to a sheet (local shadows global).
```ts
hf.addNamedExpression('TAX_RATE', '0.21');
hf.addNamedExpression('TOTAL_REVENUE', '=SUM(Revenue!A:A)');
hf.setCellContents({ sheet: 0, col: 0, row: 0 }, '=1000 * TAX_RATE');
hf.listNamedExpressions(); // all registered names
```
## Custom functions
See [custom-functions.md](custom-functions.md) — `FunctionPlugin`, argument types, volatile functions, range arguments, returning arrays, error handling, aliases, localized names.
## Events
Subscribe via `.on()` on the instance. Full list: https://hyperformula.handsontable.com/docs/api/interfaces/listeners.html
```ts
hf.on('valuesUpdated', (changes) => {
changes.forEach((c) => console.log(c.address, c.newValue));
});
hf.on('sheetAdded', (addedSheetName) => { /* ... */ });
hf.on('sheetRemoved', (removedSheetName, removedSheetData) => { /* ... */ });
hf.on('sheetRenamed', (oldName, newName) => { /* ... */ });
```
## Undo / redo
```ts
hf.undo();
hf.redo();
hf.isThereSomethingToUndo();
hf.isThereSomethingToRedo();
hf.clearUndoStack();
```
Configure depth via `undoLimit` in `ConfigParams` (see [configuration.md](configuration.md)).
## Clipboard
```ts
hf.copy({ start: { sheet: 0, col: 0, row: 0 }, end: { sheet: 0, col: 1, row: 1 } });
hf.cut ({ start: { sheet: 0, col: 0, row: 0 }, end: { sheet: 0, col: 1, row: 1 } });
hf.paste({ sheet: 0, col: 5, row: 5 });
hf.isClipboardEmpty();
hf.clearClipboard();
```
@@ -1,92 +0,0 @@
# Configuration
`ConfigParams` + compatibility presets + config-specific pitfalls. Authoritative docs:
- Configuration options guide: https://hyperformula.handsontable.com/docs/guide/configuration-options.html
- ConfigParams (all options): https://hyperformula.handsontable.com/docs/api/interfaces/configparams.html
- Excel compatibility: https://hyperformula.handsontable.com/docs/guide/compatibility-with-microsoft-excel.html
- Google Sheets compatibility: https://hyperformula.handsontable.com/docs/guide/compatibility-with-google-sheets.html
## ConfigParams highlights
Pass as the second argument to any factory method.
```ts
const hf = HyperFormula.buildEmpty({
licenseKey: 'gpl-v3', // required — 'gpl-v3' for open source
precisionRounding: 10, // decimal rounding precision (default 10; was 14 before v3)
functionArgSeparator: ',', // separator between function arguments
dateFormats: ['DD/MM/YYYY', 'DD-MM-YYYY'],
nullDate: { year: 1900, month: 1, day: 1 },
leapYear1900: false, // Excel bug compat: treat 1900 as leap year
maxRows: 40000, // max rows per sheet (default 40000)
maxColumns: 18278, // max columns per sheet (default 18278 = 'ZZZ')
undoLimit: 20, // undo history depth
maxPendingLazyTransformations: 1000, // v3.3+ — cap accumulated lazy transformations before cleanup
});
```
## Excel compatibility preset
```ts
const hf = HyperFormula.buildEmpty({
licenseKey: 'gpl-v3',
useArrayArithmetic: true,
useWildcards: true,
evaluateNullToZero: true,
leapYear1900: true, // Excel's intentional 1900 leap-year bug
ignoreWhiteSpace: 'any',
caseSensitive: false,
accentSensitive: true,
});
```
## Google Sheets compatibility preset
Defaults already match Google Sheets behavior (`leapYear1900: false`, `useArrayArithmetic: false`).
```ts
const hf = HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' });
```
## Config-specific pitfalls
### Vue 3
Vue 3 requires wrapping the instance with `markRaw` — see [vue3.md](vue3.md) for the full integration guide (React / Angular / Svelte need no special handling).
### `functionArgSeparator` vs `thousandSeparator` collision
These two options **cannot share a character**. European locales that use `,` as thousand separator must change the argument separator:
```ts
const hf = HyperFormula.buildEmpty({
licenseKey: 'gpl-v3',
thousandSeparator: ',',
decimalSeparator: '.',
functionArgSeparator: ';', // must differ from thousandSeparator
});
```
### Node.js must have full ICU
String comparisons (used in `MATCH`, `VLOOKUP`, sorting) can silently produce wrong results on Node.js < 13 or builds without full ICU. Verify at startup:
```ts
// Must report 'full' (or a detailed ICU version), not 'small'.
console.log(process.versions.icu);
```
### Tuning `maxPendingLazyTransformations` (v3.3+)
HyperFormula defers some structural transformations (row/column insertions, moves) and applies them lazily. In long-lived instances with heavy mutation throughput — bulk imports, frequent undo/redo, scripted batch edits — the pending queue can grow before cleanup. v3.3 fixed the unbounded-growth leak; this option lets you cap the queue explicitly. Lower values trade memory for more frequent flush work; the default is suitable for typical UI workloads.
### `precisionRounding` default changed in v3
Before v3 the default was `14`. It is now `10`. Calculations relying on the old precision need an explicit override:
```ts
const hf = HyperFormula.buildEmpty({
licenseKey: 'gpl-v3',
precisionRounding: 14, // pre-v3 behavior
});
```
@@ -1,174 +0,0 @@
# Custom Functions
Extend HyperFormula with your own functions via `FunctionPlugin`. Authoritative doc:
- Custom functions guide: https://hyperformula.handsontable.com/docs/guide/custom-functions.html
## Minimal plugin
Register **before** creating any instance — functions registered after `buildFromArray` / `buildFromSheets` / `buildEmpty` won't be available.
```ts
import { HyperFormula, FunctionPlugin, FunctionArgumentType } from 'hyperformula';
class MyPlugin extends FunctionPlugin {
greet(ast, state) {
return this.runFunction(
ast.args,
state,
this.metadata('GREET'),
(name) => `Hello, ${name}!`
);
}
}
MyPlugin.implementedFunctions = {
GREET: {
method: 'greet',
parameters: [{ argumentType: FunctionArgumentType.STRING }],
},
};
const translations = { enGB: { GREET: 'GREET' }, enUS: { GREET: 'GREET' } };
// PITFALL: registration MUST happen before any instance is built.
HyperFormula.registerFunctionPlugin(MyPlugin, translations);
const hf = HyperFormula.buildFromArray(
[['World', '=GREET(A1)']],
{ licenseKey: 'gpl-v3' }
);
console.log(hf.getCellValue({ sheet: 0, col: 1, row: 0 })); // "Hello, World!"
```
## Volatile functions
Mark a function as volatile so it recalculates on every change (like `RAND` or `NOW`). Volatile functions are expensive in large sheets — use sparingly.
```ts
MyPlugin.implementedFunctions = {
RAND_ID: {
method: 'randId',
parameters: [],
isVolatile: true,
},
};
```
## Argument types
```ts
import { FunctionArgumentType } from 'hyperformula';
// FunctionArgumentType.NUMBER
// FunctionArgumentType.STRING
// FunctionArgumentType.BOOLEAN
// FunctionArgumentType.NOERROR
// FunctionArgumentType.SCALAR
// FunctionArgumentType.RANGE // accepts a cell range
// FunctionArgumentType.ANY
```
Use `optionalArg: true`, `defaultValue`, and `minValue` / `maxValue` to validate arguments declaratively:
```ts
MyPlugin.implementedFunctions = {
CLAMP: {
method: 'clamp',
parameters: [
{ argumentType: FunctionArgumentType.NUMBER },
{ argumentType: FunctionArgumentType.NUMBER, optionalArg: true, defaultValue: 0 },
{ argumentType: FunctionArgumentType.NUMBER, optionalArg: true, defaultValue: 100 },
],
},
};
```
## Range arguments
Use `RANGE` argument type when the function takes a cell area. The callback receives the range as a 2D array.
```ts
class SumPositivesPlugin extends FunctionPlugin {
sumPositives(ast, state) {
return this.runFunction(
ast.args,
state,
this.metadata('SUM_POSITIVES'),
(range) => {
let total = 0;
for (const row of range.data) {
for (const v of row) if (typeof v === 'number' && v > 0) total += v;
}
return total;
}
);
}
}
SumPositivesPlugin.implementedFunctions = {
SUM_POSITIVES: {
method: 'sumPositives',
parameters: [{ argumentType: FunctionArgumentType.RANGE }],
},
};
```
## Returning arrays
Custom functions can return 2D arrays. **PITFALL:** result arrays don't auto-resize when upstream dependencies change — the footprint is fixed at first evaluation.
```ts
class SplitPlugin extends FunctionPlugin {
splitToRow(ast, state) {
return this.runFunction(
ast.args,
state,
this.metadata('SPLIT_ROW'),
(text, sep) => [text.split(sep)] // 2D array: one row, N cols
);
}
}
```
## Error handling
Return a `CellError` to signal a formula error:
```ts
import { CellError, ErrorType } from 'hyperformula';
class DividePlugin extends FunctionPlugin {
safeDivide(ast, state) {
return this.runFunction(
ast.args,
state,
this.metadata('SAFE_DIVIDE'),
(a, b) => (b === 0 ? new CellError(ErrorType.DIV_BY_ZERO) : a / b)
);
}
}
```
## Aliases and localized names
Expose one method under multiple function names, or localize:
```ts
MyPlugin.aliases = {
HELLO: 'GREET', // calling =HELLO(x) runs the greet() method
};
const translations = {
enGB: { GREET: 'GREET' },
plPL: { GREET: 'POWITAJ' }, // =POWITAJ("Świat") in Polish
};
HyperFormula.registerFunctionPlugin(MyPlugin, translations);
```
## Unregister / introspect
```ts
HyperFormula.unregisterFunctionPlugin(MyPlugin);
HyperFormula.unregisterFunction('GREET');
HyperFormula.getRegisteredFunctionNames('enGB');
```
@@ -1,69 +0,0 @@
# Error Handling
Inspect cell values that may be errors, tell error types apart, and check value shape. Authoritative docs:
- Types of errors: https://hyperformula.handsontable.com/docs/guide/types-of-errors.html
- Types of values: https://hyperformula.handsontable.com/docs/guide/types-of-values.html
## Always check `CellError` before using a result
Cell values can be errors (`#DIV/0!`, `#VALUE!`, `#REF!`, etc.). Always test before using a result.
```ts
import { CellError, ErrorType } from 'hyperformula';
const value = hf.getCellValue({ sheet: 0, col: 0, row: 0 });
if (value instanceof CellError) {
// ErrorType enum: DIV_BY_ZERO, VALUE, REF, NAME, NUM, NA, CYCLE, ERROR
switch (value.type) {
case ErrorType.CYCLE:
console.log('Circular reference');
break;
case ErrorType.NAME:
// Usually: function not registered (check plugin registration or i18n language)
console.log('Unknown name:', value.message);
break;
default:
console.log('Error:', value.type, value.message);
}
} else {
console.log('Value:', value);
}
```
## `#CYCLE!` is HyperFormula-specific
Standard spreadsheet apps report cycles differently. HyperFormula's `IF` also reports cycles for all branches, even unreachable ones — this can produce `#CYCLE!` in formulas that Excel or Sheets would evaluate.
## Inspect value shape without catching errors
```ts
import { CellValueDetailedType } from 'hyperformula';
hf.getCellValueDetailedType({ sheet: 0, col: 0, row: 0 });
// → CellValueDetailedType.NUMBER | STRING | BOOLEAN | ERROR | EMPTY | ...
```
Use the detailed type when you need to distinguish e.g. number vs empty without touching the value.
## Returning errors from custom functions
Custom `FunctionPlugin` methods can return a `CellError` to surface a formula error. See [custom-functions.md](custom-functions.md) for the full pattern.
```ts
import { CellError, ErrorType } from 'hyperformula';
return new CellError(ErrorType.DIV_BY_ZERO);
```
## Common causes
| Error | Common cause |
|---|---|
| `#NAME?` | Function not registered (check `registerFunctionPlugin` order or `language` config) |
| `#CYCLE!` | Circular reference — or an `IF` branch that *could* produce one |
| `#REF!` | Deleted row/column broke a formula reference |
| `#VALUE!` | Type mismatch — e.g. passing a string where a number is expected |
| `#DIV/0!` | Division by zero, including empty cells coerced to 0 |
| `#NUM!` | Out-of-range numeric result (e.g. `SQRT(-1)`) |
| `#N/A` | Lookup miss (`MATCH`, `VLOOKUP`) or propagated from an upstream `#N/A` |
@@ -1,48 +0,0 @@
# General Pitfalls
Cross-cutting gotchas that aren't tied to a specific API or config option. Authoritative docs:
- Known limitations: https://hyperformula.handsontable.com/docs/guide/known-limitations.html
- Built-in functions (full list): https://hyperformula.handsontable.com/docs/guide/built-in-functions.html
- Runtime differences vs Excel/Sheets: https://hyperformula.handsontable.com/docs/guide/list-of-differences.html
## Error handling
See [error-handling.md](error-handling.md) — checking `CellError`, `ErrorType` enum, `getCellValueDetailedType`, common error causes.
## Always call `destroy()` in long-running apps
HyperFormula maintains internal data structures (dependency graph, address mapping) that are **not** garbage-collected until `destroy()` is called. Leaking instances in servers or SPAs accumulates memory.
```ts
hf.destroy();
// After destroy() the instance is unusable — create a new one if needed.
```
v3.3 fixed two longstanding leak sources inside live instances — pending lazy transformations and undo/redo history were not being trimmed. If you maintain very long-lived instances with heavy mutation throughput, also see `maxPendingLazyTransformations` in [configuration.md](configuration.md) to bound the lazy-transformation queue. `destroy()` is still mandatory at teardown.
## Force a string that looks like a formula
Prefix with `'` (apostrophe) to store the literal text instead of evaluating.
```ts
// Stored as the literal string "=SUM(1,2)", not a formula:
hf.setCellContents({ sheet: 0, col: 0, row: 0 }, "'=SUM(1,2)");
```
## Don't assume Excel parity
~68% of Excel functions are covered. Runtime differences exist even for implemented functions. Before relying on behavior, check:
- Full built-in list: https://hyperformula.handsontable.com/docs/guide/built-in-functions.html
- Runtime differences: https://hyperformula.handsontable.com/docs/guide/list-of-differences.html
## `licenseKey` is always required
Every factory method (`buildFromArray`, `buildFromSheets`, `buildEmpty`) requires `licenseKey`. Use `'gpl-v3'` for open-source use or your commercial key.
## Known hard limits
- **Single workbook per instance** — no multi-workbook support.
- No 3D references, dynamic arrays, async functions, structured references ("Tables"), or relative named expressions.
- `IF` reports cycles for all branches, even unreachable ones.
- Custom function result arrays don't auto-resize when dependencies change.
@@ -1,82 +0,0 @@
# Getting Started
Install, create an instance, read/write cells. Authoritative docs:
- Client-side install: https://hyperformula.handsontable.com/docs/guide/client-side-installation.html
- Server-side install: https://hyperformula.handsontable.com/docs/guide/server-side-installation.html
- Basic usage: https://hyperformula.handsontable.com/docs/guide/basic-usage.html
## Install
### npm
```bash
npm install hyperformula
```
### CDN (jsDelivr, pinned version)
```html
<script src="https://cdn.jsdelivr.net/npm/hyperformula@3.2.0/dist/hyperformula.full.min.js"></script>
```
### Node.js
Same npm install. Requires Node.js 13+ with full ICU for locale-aware string comparison.
## Create an instance
Every factory method requires `licenseKey`. Use `'gpl-v3'` for open-source use or your commercial key.
### `buildFromArray` — single sheet from a 2D array
```ts
import { HyperFormula } from 'hyperformula';
const hf = HyperFormula.buildFromArray(
[
['10', '20', '=SUM(A1:B1)'],
['30', '40', '=SUM(A2:B2)'],
],
{ licenseKey: 'gpl-v3' }
);
console.log(hf.getCellValue({ sheet: 0, col: 2, row: 0 })); // 30
```
### `buildFromSheets` — multi-sheet workbook
```ts
const hf = HyperFormula.buildFromSheets(
{
Revenue: [['100', '200', '=SUM(A1:B1)']],
Expenses: [['50', '=Revenue!C1 - A1']],
},
{ licenseKey: 'gpl-v3' }
);
```
Cross-sheet references use `SheetName!CellRef` syntax.
### `buildEmpty` — start empty, add sheets later
```ts
const hf = HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' });
const sheetName = hf.addSheet('Data');
const sheetId = hf.getSheetId(sheetName);
hf.setCellContents({ sheet: sheetId, col: 0, row: 0 }, [
['10', '20', '=SUM(A1:B1)'],
]);
```
## Cell addresses
All addresses use zero-indexed `{ sheet, col, row }`. Convert to/from A1 notation with built-in helpers:
```ts
hf.simpleCellAddressFromString('B3', 0);
// → { sheet: 0, col: 1, row: 2 }
hf.simpleCellAddressToString({ sheet: 0, col: 1, row: 2 }, 0);
// → 'B3'
```
@@ -1,72 +0,0 @@
# Vue 3
HyperFormula + Vue 3 integration. Authoritative doc:
- Vue integration guide: https://hyperformula.handsontable.com/docs/guide/integration-with-vue.html
## Always wrap the instance with `markRaw`
Vue 3's Composition API wraps objects in a reactive Proxy that intercepts property access and **corrupts HyperFormula's internal state**, causing silent data corruption or crashes. React, Angular, and Svelte need no special handling.
```ts
import { markRaw } from 'vue';
import { HyperFormula } from 'hyperformula';
const hf = markRaw(
HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' })
);
```
Use the same wrapper no matter where the instance is held — `ref`, `reactive`, component state, a store (Pinia/Vuex), or a composable. If the raw HyperFormula instance ever reaches Vue's reactivity system, it must be marked.
## Composition API example
```ts
import { onBeforeUnmount, ref } from 'vue';
import { markRaw } from 'vue';
import { HyperFormula } from 'hyperformula';
export function useHyperFormula() {
const hf = markRaw(
HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' })
);
// Free internal data structures when the component unmounts.
onBeforeUnmount(() => hf.destroy());
const result = ref<unknown>(null);
function evaluate(formula: string) {
hf.setCellContents({ sheet: 0, col: 0, row: 0 }, formula);
result.value = hf.getCellValue({ sheet: 0, col: 0, row: 0 });
}
return { evaluate, result };
}
```
## Pinia / Vuex store
```ts
import { defineStore } from 'pinia';
import { markRaw } from 'vue';
import { HyperFormula } from 'hyperformula';
export const useCalcStore = defineStore('calc', {
state: () => ({
hf: markRaw(
HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' })
),
}),
});
```
## Cleanup
HyperFormula's internal graph is **not** garbage-collected until `destroy()` is called. In SPAs this accumulates memory over time.
```ts
import { onBeforeUnmount } from 'vue';
onBeforeUnmount(() => hf.destroy());
// After destroy() the instance is unusable — create a new one if needed.
```
+1 -1
View File
@@ -58,7 +58,7 @@ jobs:
- name: Update appLoc in index.html
run: |
cd client
replace-in-files --regex='appLoc=".*?"' --replacement='appLoc="/proj/sasjs/genesis-mocks"' ./src/index.html
replace-in-files --regex='appLoc=".*?"' --replacement='appLoc="/Public/app/devtest"' ./src/index.html
- name: Build Frontend
run: |
+17 -79
View File
@@ -47,9 +47,9 @@ jobs:
- name: Check audit
# Audit should fail and stop the CI if critical vulnerability found
run: |
npm audit --audit-level=critical --omit=dev
npm audit --omit=dev
cd ./sas
npm audit --audit-level=critical --omit=dev
npm audit --omit=dev
cd ../client
npm audit --audit-level=critical --omit=dev
@@ -161,16 +161,6 @@ jobs:
release:
runs-on: ubuntu-latest
needs: [Build-production-and-ng-test, Build-and-test-development]
# The semantic-release step below uses the per-job token injected by Gitea
# Actions (secrets.GITEA_TOKEN). That token is scoped to THIS repository
# and its permissions are derived from this block.
# contents: write -> push CHANGELOG.md / package.json commit + tag, and
# create the Release via the Gitea API.
# The TSDOC_TOKEN and CODE_DATACONTROLLER_IO secrets continue to be used
# because they target *different* servers (webdoc.datacontroller.io and
# code.datacontroller.io), not the Gitea API.
permissions:
contents: write
steps:
- uses: actions/checkout@v4
@@ -203,41 +193,12 @@ jobs:
npm run build
- name: Create Empty Release (assets are posted later)
env:
# Use the ephemeral per-job token injected by Gitea Actions instead
# of a manually-managed PAT. This token always reports push=true on
# its own repo via the Gitea API, which is what the
# @saithodev/semantic-release-gitea verifyConditions step checks.
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_URL: ${{ gitea.server_url }}
run: |
set -euo pipefail
npm i
npm i -g semantic-release
# We do a semantic-release DRY RUN to make the job fail if there are
# no changes to release. The previous implementation piped --dry-run
# into `grep`, which swallowed semantic-release's non-zero exit codes
# (e.g. auth/verifyConditions failures) — the real error then only
# surfaced from the second invocation. Capture output and inspect it
# so authentication failures fail the step cleanly with context.
echo "::group::semantic-release --dry-run"
DRY_EXIT=0
DRY_OUTPUT=$(semantic-release --dry-run 2>&1) || DRY_EXIT=$?
echo "$DRY_OUTPUT"
echo "::endgroup::"
if [ "$DRY_EXIT" -ne 0 ]; then
echo "::error::semantic-release dry-run failed (exit $DRY_EXIT). See log above; common cause is the Gitea token lacking push permission on this repo (verifyConditions of @saithodev/semantic-release-gitea)."
exit "$DRY_EXIT"
fi
if echo "$DRY_OUTPUT" | grep -q "There are no relevant changes, so no new version is released."; then
echo "::error::No releasable changes since last tag - aborting release job."
exit 1
fi
semantic-release
# We do a semantic-release DRY RUN to make the job fail if there are no changes to release
GITEA_TOKEN=${{ secrets.RELEASE_TOKEN }} GITEA_URL=https://git.datacontroller.io semantic-release --dry-run | grep -q "There are no relevant changes, so no new version is released." && exit 1
GITEA_TOKEN=${{ secrets.RELEASE_TOKEN }} GITEA_URL=https://git.datacontroller.io semantic-release
- name: Frontend Build
description: Must be created AFTER the release as the version (git tag) is used in the interface
@@ -311,40 +272,17 @@ jobs:
surfer put --token ${{ secrets.CODE_DATACONTROLLER_IO }} --server code.datacontroller.io sasjsbuild/sasdocs/* /
- name: Upload assets to release
env:
# Use the same ephemeral per-job Gitea Actions token that the
# "Create Empty Release" step uses for semantic-release. It is scoped
# to this repo and is granted write access via the workflow's
# `permissions:` block at the top of the file (see contents: write).
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
# Send the token via Authorization header rather than ?access_token=
# (the query-string form is deprecated and leaks into access logs).
AUTH_HEADER="Authorization: token ${GITEA_TOKEN}"
BASE="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
RELEASE_JSON=$(curl -k --fail-with-body -sS -H "$AUTH_HEADER" "$BASE/releases/latest")
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id')
RELEASE_BODY=$(echo "$RELEASE_JSON" | jq -r '.body')
# Update body (also confirms the token has contents:write on this repo)
curl -k --fail-with-body -sS -X PATCH \
-H "$AUTH_HEADER" \
-H 'Content-Type: application/json' \
--data "$(jq -n --arg body "$RELEASE_BODY"$'\n\nFor installation instructions, please visit https://docs.datacontroller.io/' \
'{draft:false, body:$body}')" \
"$BASE/releases/$RELEASE_ID"
RELEASE_ID=`curl -k 'https://git.datacontroller.io/api/v1/repos/dc/dc/releases/latest?access_token=${{ secrets.RELEASE_TOKEN }}' | jq -r '.id'`
RELEASE_BODY=`curl -k 'https://git.datacontroller.io/api/v1/repos/dc/dc/releases/latest?access_token=${{ secrets.RELEASE_TOKEN }}' | jq -r '.body'`
# Update body
curl --data '{"draft": false,"body":"'"$RELEASE_BODY\n\nFor installation instructions, please visit https://docs.datacontroller.io/"'"}' -X PATCH --header 'Content-Type: application/json' -k https://git.datacontroller.io/api/v1/repos/dc/dc/releases/$RELEASE_ID?access_token=${{ secrets.RELEASE_TOKEN }}
# Upload assets
URL="$BASE/releases/$RELEASE_ID/assets"
for f in frontend.zip \
sas/demostream_sas9.sas \
sas/viya.sas \
sas/sasjs_server.json.zip \
sas/sas9.sas \
sas/viya_noweb.sas \
sas/viya_noweb.json; do
echo "Uploading $f ..."
curl -k --fail-with-body -sS -H "$AUTH_HEADER" "$URL" -F "attachment=@$f"
done
URL="https://git.datacontroller.io/api/v1/repos/dc/dc/releases/$RELEASE_ID/assets?access_token=${{ secrets.RELEASE_TOKEN }}"
curl -k $URL -F attachment=@frontend.zip
curl -k $URL -F attachment=@sas/demostream_sas9.sas
curl -k $URL -F attachment=@sas/viya.sas
curl -k $URL -F attachment=@sas/sasjs_server.json.zip
curl -k $URL -F attachment=@sas/sas9.sas
curl -k $URL -F attachment=@sas/viya_noweb.sas
curl -k $URL -F attachment=@sas/viya_noweb.json
-55
View File
@@ -1,58 +1,3 @@
## [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)
-4
View File
@@ -1,4 +0,0 @@
# Auto-generated at build time (node ./src/version.ts) — gitignored, never linted.
# Formerly skipped via root .gitignore when prettier ran from the repo root;
# now that lint runs from client/, prettier reads this CWD-local ignore instead.
src/environments/version.ts
+1 -8
View File
@@ -68,14 +68,7 @@
}
],
"styles": ["src/styles.scss"],
"scripts": [
"node_modules/marked/marked.min.js",
{
"input": "src/assets/va-early.js",
"bundleName": "va-early",
"inject": true
}
],
"scripts": ["node_modules/marked/marked.min.js"],
"webWorkerTsConfig": "tsconfig.worker.json",
"main": "src/main.ts"
},
-130
View File
@@ -1,130 +0,0 @@
const hostUrl = Cypress.env('hosturl')
const appLocation = Cypress.env('appLocation')
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
const serverType = Cypress.env('serverType')
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
// SAS Visual Analytics data-driven content embed mode (`?embed=va`):
// - chrome is hidden (same as embed=true),
// - the editor opens in edit mode immediately (not read-only),
// - the only action button is a single Submit below the grid (stubbed in v1),
// - the top button bar and Add Record are hidden.
//
// The detailed PK+label data-merge logic is covered by the VaMessagingService
// unit spec; here we only assert the deterministic UI layout and that pushing a
// VA postMessage does not break the page.
context('embed=va (VA data-driven content) tests: ', function () {
this.beforeAll(() => {
cy.visit(`${hostUrl}/SASLogon/logout`)
cy.loginAndUpdateValidKey()
})
this.beforeEach(() => {
cy.visit(hostUrl + appLocation)
visitPage('home')
})
it('1 | opens the editor in VA mode: chrome hidden, editable, single bottom Submit', () => {
openEditorInVaMode('mpe_x_test', () => {
// Chrome hidden (same as embed=true)
cy.get('header.app-header').should('not.exist')
// Grid present and in edit mode (read-only Filter/Edit/Upload bar absent)
cy.get('#hotTable', { timeout: longerCommandTimeout }).should('exist')
cy.get('.btnCtrl button').contains('Edit').should('not.exist')
cy.get('.btnCtrl button').contains('Filter').should('not.exist')
// Top edit-mode buttons hidden in VA mode
cy.get('.btnCtrl button.btn-outline-danger').should('not.exist') // Cancel
cy.get('.btnCtrl').contains('Add Row').should('not.exist')
// Bottom Add Record hidden
cy.contains('button', 'Add Record').should('not.exist')
// Exactly one Submit button, below the grid, disabled in v1
cy.get('button').filter(':contains("Submit")').should('have.length', 1)
cy.get('button').contains('Submit').should('be.disabled')
})
})
it('2 | accepts a VA postMessage and stays in VA mode', () => {
// A VA message drives column visibility + filtering; an empty/unmatched
// message reloads the editor (unfiltered) while preserving ?embed=va.
openEditorInVaMode('mpe_x_test', () => {
cy.window().then((win) => {
win.postMessage(
{
version: '1',
resultName: 'dd1',
rowCount: 0,
availableRowCount: 0,
data: [],
columns: [],
parameters: []
},
'*'
)
})
// Editor still loads in VA mode after the message (chrome hidden, grid up)
cy.get('#hotTable', { timeout: longerCommandTimeout }).should('exist')
cy.get('header.app-header').should('not.exist')
cy.get('button').contains('Submit').should('exist')
})
})
})
// Opens a table from the tree (which routes to #/editor/LIB.TABLE), then
// re-visits the same route with ?embed=va so the app parses VA embed mode.
const openEditorInVaMode = (tablename: string, callback?: any) => {
openTableFromTree(libraryToOpenIncludes, tablename)
cy.get('#hotTable', { timeout: longerCommandTimeout })
.should('exist')
.then(() => {
cy.url().then((url) => {
const separator = url.includes('?') ? '&' : '?'
cy.visit(`${url}${separator}embed=va`)
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
'not.exist'
)
if (callback) callback()
})
})
}
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
cy.get('.app-loading', { timeout: longerCommandTimeout })
.should('not.exist')
.then(() => {
cy.get('.nav-tree clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
}).then((treeNodes: any) => {
let viyaLib
for (let node of treeNodes) {
if (node.innerText.toLowerCase().includes(libNameIncludes)) {
viyaLib = node
break
}
}
cy.get(viyaLib).within(() => {
cy.get('.clr-tree-node-content-container > button').click()
cy.get('.clr-treenode-link').then((innerNodes: any) => {
for (let innerNode of innerNodes) {
if (innerNode.innerText.toLowerCase().includes(tablename)) {
innerNode.click()
break
}
}
})
})
})
})
}
const visitPage = (url: string) => {
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
}
+5 -5
View File
@@ -559,13 +559,13 @@ const checkResultOfXLSUpload = (callback?: any) => {
expect(cell.innerText).to.equal('▼\nOption 1')
cell = data[0].children[0].children[5]
expect(cell.innerText).to.equal('42')
// Cells render the raw stored ISO string verbatim (locale-independent;
// see registerIntlCellTypes). date -> YYYY-MM-DD, datetime ->
// YYYY-MM-DD HH:mm:ss, time -> HH:mm:ss.
// Cells render locale-formatted (Cypress pins --lang=en-GB); stored value
// stays raw ISO. date -> dd/mm/yyyy, datetime -> dd/mm/yyyy, HH:mm:ss,
// time -> HH:mm:ss (en-GB 24h, identical to the raw ISO time).
cell = data[0].children[0].children[6]
expect(cell.innerText).to.equal('1960-02-12')
expect(cell.innerText).to.equal('12/02/1960')
cell = data[0].children[0].children[7]
expect(cell.innerText).to.equal('1960-01-01 00:00:42')
expect(cell.innerText).to.equal('01/01/1960, 00:00:42')
cell = data[0].children[0].children[8]
expect(cell.innerText).to.equal('00:00:42')
-17
View File
@@ -96,7 +96,6 @@
"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",
@@ -21324,22 +21323,6 @@
"node": ">= 0.8.0"
}
},
"node_modules/prettier": {
"version": "3.8.4",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz",
"integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-bytes": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
+1 -6
View File
@@ -17,11 +17,7 @@
"deploy_viya": "rsync -avhe ssh ./dist/* --delete ${npm_config_account}@sas.4gl.io:/var/www/html/${npm_config_account}/dc/dev",
"deploy_sasjs": "rsync -avhe ssh ./dist/* --delete root@${npm_config_account}.4gl.io:/var/www/html/dc/dev",
"viyabuild": "cd build; ./viyabuild.sh",
"lint": "npm run lint:fix",
"lint:fix": "npx prettier --write \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
"lint:fix:silent": "npx prettier --log-level silent --write \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
"lint:check": "npx prettier --check \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
"lint:check:silent": "npx prettier --log-level silent --check \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
"lint": "cd .. && npm run lint",
"test": "npx ng test",
"test:headless": "npx ng test --no-watch --no-progress --browsers ChromeHeadlessCI",
"watch": "ng test watch=true",
@@ -130,7 +126,6 @@
"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",
+1 -7
View File
@@ -55,12 +55,7 @@ export interface HandsontableStaticConfig {
* Cached viyaApi collections, search and selected endpoint
*/
export const globals: {
embed: boolean | 'va'
/**
* VA embed filter behavior: 'live' = auto-apply, 'confirm' = manual Apply.
* Defaults to 'live'; toggled by the Auto-apply checkbox in the editor.
*/
vaApplyMode: 'live' | 'confirm'
embed: boolean
rootParam: string
dcLib: string
xlmaps: XLMapListItem[]
@@ -76,7 +71,6 @@ export const globals: {
[key: string]: any
} = {
embed: false,
vaApplyMode: 'live',
rootParam: <string>'',
dcLib: '',
xlmaps: [],
-20
View File
@@ -256,26 +256,6 @@
<!-- <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>
-10
View File
@@ -1,10 +0,0 @@
.va-back-bar {
position: sticky;
top: 0;
z-index: 1000;
display: flex;
justify-content: flex-start;
padding: 0.35rem 0.6rem;
background: #fafafa;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
+7 -24
View File
@@ -11,7 +11,6 @@ import { Location } from '@angular/common'
import '@clr/icons'
import '@clr/icons/shapes/all-shapes'
import { globals } from './_globals'
import { parseEmbedParam } from './shared/utils/parse-embed-param'
import moment from 'moment'
import { EventService } from './services/event.service'
import { AppService } from './services/app.service'
@@ -146,10 +145,13 @@ export class AppComponent {
})
const hashQuery = window.location.hash.split('?')[1]
if (hashQuery && new URLSearchParams(hashQuery).get('embed') !== null) {
const embed = parseEmbedParam(hashQuery)
globals.embed = embed
this.embed = embed
if (hashQuery) {
const embedParam = new URLSearchParams(hashQuery).get('embed')
if (embedParam !== null) {
const isEmbed = embedParam !== 'false'
globals.embed = isEmbed
this.embed = isEmbed
}
}
this.subscribeToShowAbortModal()
@@ -352,23 +354,4 @@ export class AppComponent {
public openLicencingPage() {
this.router.navigateByUrl('/licensing/update')
}
/** "lib.table" of the last-opened editor table (for the VA embed back button). */
get vaEditorLibds(): string {
const e = globals.editor
return e?.library && e?.table ? `${e.library}.${e.table}` : ''
}
/**
* VA embed only: returns to the editor for the last-opened table. Lets users
* who land on the stage/approve/review pages get back without knowing DC's
* page paths. `globals.embed` (set at app init) keeps the editor in VA mode.
*/
public backToEditor(): void {
if (this.vaEditorLibds) {
this.router.navigate(['/editor/' + this.vaEditorLibds], {
queryParams: { embed: 'va' }
})
}
}
}
+15 -16
View File
@@ -8,15 +8,7 @@ import { RouterModule, Routes } from '@angular/router'
import { NotFoundComponent } from './not-found/not-found.component'
import { DeployModule } from './deploy/deploy.module'
import { EditorModule } from './editor/editor.module'
import { HomeModule } from './home/home.module'
import { LicensingModule } from './licensing/licensing.module'
import { ReviewModule } from './review/review.module'
import { ReviewRouteComponent } from './routes/review-route/review-route.component'
import { StageModule } from './stage/stage.module'
import { SystemModule } from './system/system.module'
import { ViewerModule } from './viewer/viewer.module'
/**
* Defining routes
@@ -25,7 +17,8 @@ export const ROUTES: Routes = [
{ path: '', redirectTo: 'home', pathMatch: 'full' },
{
path: 'view',
loadChildren: () => ViewerModule
loadChildren: () =>
import('./viewer/viewer.module').then((m) => m.ViewerModule)
},
{
/**
@@ -37,36 +30,42 @@ export const ROUTES: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'toapprove' },
{
path: '',
loadChildren: () => ReviewModule
loadChildren: () =>
import('./review/review.module').then((m) => m.ReviewModule)
}
]
},
{
path: 'licensing',
loadChildren: () => LicensingModule
loadChildren: () =>
import('./licensing/licensing.module').then((m) => m.LicensingModule)
},
{
path: 'home',
loadChildren: () => HomeModule
loadChildren: () => import('./home/home.module').then((m) => m.HomeModule)
},
{
/**
* Load editor module with subroutes
*/
path: 'editor',
loadChildren: () => EditorModule
loadChildren: () =>
import('./editor/editor.module').then((m) => m.EditorModule)
},
{
path: 'stage',
loadChildren: () => StageModule
loadChildren: () =>
import('./stage/stage.module').then((m) => m.StageModule)
},
{
path: 'system',
loadChildren: () => SystemModule
loadChildren: () =>
import('./system/system.module').then((m) => m.SystemModule)
},
{
path: 'deploy',
loadChildren: () => DeployModule
loadChildren: () =>
import('./deploy/deploy.module').then((m) => m.DeployModule)
},
{ path: '**', component: NotFoundComponent }
]
+2 -69
View File
@@ -285,9 +285,7 @@
</button>
</ng-container>
<ng-container
*ngIf="!hotTable.readOnly && !uploadPreview && !isVaEmbed"
>
<ng-container *ngIf="!hotTable.readOnly && !uploadPreview">
<button
type="button"
class="btn btn-sm btn-icon btn-outline-danger"
@@ -437,9 +435,7 @@
<div>
<clr-tooltip
*ngIf="
tableTrue && !restrictions.removeAddRecordButton && !isVaEmbed
"
*ngIf="tableTrue && !restrictions.removeAddRecordButton"
>
<button
clrTooltipTrigger
@@ -470,69 +466,6 @@
>
</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 &&
@@ -1,25 +0,0 @@
.va-filter-controls {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
.va-toggle {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin: 0;
cursor: pointer;
}
.va-pending-text {
font-style: italic;
color: #8a5200;
}
}
.va-loading-text {
font-style: italic;
color: #0072a3;
}
+65 -394
View File
@@ -34,7 +34,7 @@ import {
Version
} from '../models/sas/editors-getdata.model'
import { DataFormat } from '../models/sas/common/DateFormat'
import { Approver, ExcelRule, QueryClause } from '../models/TableData'
import { Approver, ExcelRule } from '../models/TableData'
import { QueryComponent } from '../query/query.component'
import { EventService } from '../services/event.service'
import { HelperService } from '../services/helper.service'
@@ -49,10 +49,7 @@ import { excelRound } from '../shared/dc-validator/utils/excelRound'
import { isEmpty } from '../shared/dc-validator/utils/isEmpty'
import { globals } from '../_globals'
import { UploadStaterComponent } from './components/upload-stater/upload-stater.component'
import {
DynamicCellValidation,
DynamicExtendedCellValidation
} from './models/dynamicExtendedCellValidation'
import { DynamicExtendedCellValidation } from './models/dynamicExtendedCellValidation'
import { EditRecordInputFocusedEvent } from './models/edit-record/edit-record-events'
import { EditorRestrictions } from './models/editor-restrictions.model'
import { parseTableColumns } from './utils/grid.utils'
@@ -64,8 +61,6 @@ import {
import { LicenceService } from '../services/licence.service'
import { FileUploadEncoding } from '../models/FileUploadEncoding'
import { SpreadsheetService } from '../services/spreadsheet.service'
import { VaMessagingService, VaMessage } from '../services/va-messaging.service'
import { VaFilterService } from '../services/va-filter.service'
import { UploadFileResponse } from '../models/UploadFile'
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
import { ParseResult } from '../models/ParseResult.interface'
@@ -287,44 +282,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
get embed() {
return globals.embed
}
/** True when running as a SAS Visual Analytics data-driven content object. */
get isVaEmbed() {
return globals.embed === 'va'
}
/**
* VA filter mode. Live = auto-apply each VA change; confirm = stage the change
* and wait for the user to click Apply. Backed by globals (toggled by the
* Auto-apply checkbox) so it persists across the editor reloads a filter apply
* triggers. Defaults to 'live'.
*/
get vaAutoApply(): boolean {
return globals.vaApplyMode !== 'confirm'
}
/**
* VA filter UI status (shown in both modes):
* - 'pending' : a VA change was received and not yet fetched (debouncing in
* live mode, or awaiting Apply in confirm mode);
* - 'loading' : the filter is being fetched (saveQuery), just before DC's
* native "Loading Table" takes over for the data;
* - 'idle' : nothing pending.
*/
public vaFilterStatus: 'idle' | 'pending' | 'loading' = 'idle'
/** The staged clauses/signature for the pending VA filter. */
private vaPendingClauses: QueryClause[] | null = null
private vaPendingSignature = ''
/** Removes the VA postMessage listener; set while subscribed. */
private vaUnsubscribe?: () => void
/** Guards against overlapping VA filter reloads. */
private vaApplyingFilter = false
/** Debounce timer coalescing a burst of VA messages into one apply. */
private vaDebounceTimer?: ReturnType<typeof setTimeout>
/**
* Debounce window (ms) for VA messages. Sized so a burst of rapid control
* clicks coalesces into a single apply (the last one) BEFORE any filter
* reload. Note VA itself also throttles how often it posts, so this stacks
* with that cadence.
*/
private static readonly VA_DEBOUNCE_MS = 800
public tableTrue: boolean | undefined
public saveLoading = false
public approvers: string[] = []
@@ -437,6 +394,30 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
message: string
} = { open: false, title: '', message: '' }
private confirmModalResolver: ((v: boolean) => void) | null = null
// Index maps into the positional arrays returned by getdynamiccolvals.
// The DISPLAY_VALUE column was dropped from the payload (it was never consumed
// here — the dropdown source uses RAW_VALUE and rows are matched via
// DISPLAY_INDEX), so these indices are contiguous.
public extendedCellValidationFields: {
DISPLAY_INDEX: number
EXTRA_COL_NAME: number
DISPLAY_TYPE: number
RAW_VALUE_NUM: number
RAW_VALUE_CHAR: number
FORCE_FLAG: number
} = {
DISPLAY_INDEX: 0,
EXTRA_COL_NAME: 1,
DISPLAY_TYPE: 2,
RAW_VALUE_NUM: 3,
RAW_VALUE_CHAR: 4,
FORCE_FLAG: 5
}
public cellValidationFields: {
DISPLAY_INDEX: number
RAW_VALUE: number
} = { DISPLAY_INDEX: 0, RAW_VALUE: 1 }
public disabledBasicDynamicCellValidationMap: {
row: number
@@ -459,9 +440,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
private route: ActivatedRoute,
private sasService: SasService,
private cdf: ChangeDetectorRef,
private spreadsheetService: SpreadsheetService,
private vaMessaging: VaMessagingService,
private vaFilter: VaFilterService
private spreadsheetService: SpreadsheetService
) {
this.parseRestrictions()
this.setRestrictions()
@@ -955,32 +934,16 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
}
}
/**
* Reads the current multi-column sort config defensively. The sorting plugin
* may not be initialised yet (e.g. editTable() invoked right after load in VA
* embed mode, before the grid finished setup), in which case getSortConfig()
* throws on null internal state. There's no sort to preserve then, so [].
*/
private getCurrentSortConfigs(): any[] {
const hot = this.hotInstance
if (!hot) return []
try {
const plugin = hot.getPlugin('multiColumnSorting')
const cfg = plugin?.getSortConfig()
return Array.isArray(cfg) ? cfg : cfg ? [cfg] : []
} catch {
return []
}
}
editTable(previewEdit?: boolean, newRow?: boolean) {
this.toggleHotPlugin('contextMenu', true)
const hot = this.hotInstance
if (!hot) return
const columnSorting = hot.getPlugin('multiColumnSorting')
const sortConfigs = this.getCurrentSortConfigs()
const columnSortConfig = columnSorting.getSortConfig()
const sortConfigs = Array.isArray(columnSortConfig)
? columnSortConfig
: [columnSortConfig]
setTimeout(() => {
if (!previewEdit) {
@@ -1501,7 +1464,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
for (const entry of this.cellValidationSource) {
const colSource = entry.values.map(
(el: DynamicCellValidation) => el.RAW_VALUE
(el: any) => el[this.cellValidationFields.RAW_VALUE]
)
hot.batch(() => {
@@ -1517,9 +1480,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
if (!cellSource) cellSource = []
const combinedSource = [
...new Set([...cellSource, ...colSource])
] as string[]
const combinedSource = [...new Set([...cellSource, ...colSource])]
this.currentEditRecordValidator?.updateRule(entry.col, {
source: combinedSource
@@ -1688,7 +1649,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
}
const forceValue = valuesForCol.find(
(x: DynamicExtendedCellValidation) => x.FORCED_VALUE === 1
(x: DynamicExtendedCellValidation) => x.FORCE_FLAG === 1
)
if (forceValue) {
@@ -1732,15 +1693,30 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
rowOverride ? rowOverride : cellValidationEntry.row,
cellValidationEntry.col
)
const valueIndex = (
cellValidationEntry.values.find((x) => x.RAW_VALUE === cellValue) || {}
).DISPLAY_INDEX
const valueIndex = (cellValidationEntry.values.find(
(x) => x[this.cellValidationFields.RAW_VALUE] === cellValue
) || [])[this.cellValidationFields.DISPLAY_INDEX]
return (
cellValidationEntry.extended_values?.filter(
(x) => x.DISPLAY_INDEX === valueIndex
) || []
const filteredValues = cellValidationEntry.extended_values?.filter(
(x) => x[0] === valueIndex
)
const prepObj = this.helperService.deepClone(
this.extendedCellValidationFields
)
const extendedValuesObject = []
for (const extendedValue of filteredValues || []) {
const tempObj: any = {}
for (const key of Object.keys(prepObj)) {
tempObj[key] = extendedValue[prepObj[key]]
}
extendedValuesObject.push(tempObj)
}
return extendedValuesObject
}
public checkSave() {
@@ -2244,7 +2220,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
if (validationSourceIndex > -1) {
let colSource = this.cellValidationSource[
validationSourceIndex
].values.map((el) => el.RAW_VALUE)
].values.map((el) => el[this.cellValidationFields.RAW_VALUE])
const cellHadSource =
(hot.getCellMeta(row, column).source || []).length < 1
@@ -2284,9 +2260,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
) as string[]
}
const cellSourceCombined = [
...new Set([...cellSource, ...colSource])
] as string[]
const cellSourceCombined = [...new Set([...cellSource, ...colSource])]
hot.setCellMeta(row, column, 'source', cellSourceCombined)
@@ -2394,7 +2368,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
}
const colSource = res.adapterResponse.dynamic_values.map(
(el: DynamicCellValidation) => el.RAW_VALUE
(el: any) => el[this.cellValidationFields.RAW_VALUE]
)
this.currentEditRecordLoadings.splice(
@@ -2622,12 +2596,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
}
async ngOnInit() {
// VA data-driven content: register the postMessage listener as early as
// possible (like the SAS sample's load-time registration) so VA's initial
// data message isn't missed during the editor's getdata load. The message
// is buffered on the service and replayed once the grid is ready.
if (this.isVaEmbed) this.subscribeToVaMessages()
// Initialize hot table settings
this.updateHotTableSettings()
@@ -2672,12 +2640,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
}
}
// VA initial load: if VA's first message already carries a filter, apply it
// BEFORE the first getdata so the initial fetch is filtered (no full-table
// load + reload). Navigates away on success; this instance aborts its load.
// Falls back to the normal unfiltered load on timeout/no-filter/failure.
if (await this.maybeDeferInitialVaLoad()) return
if (this.libds) {
this.getdataError = false
@@ -2693,51 +2655,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
}
}
/**
* VA mode, initial (unfiltered) route only: briefly wait for VA's buffered
* first message and, if it already carries a filter, save that filter and
* navigate to /editor/<table>/<FILTER_RK> so the very first getdata is
* filtered. Returns true if it navigated (caller must abort its own load),
* false to proceed with the normal unfiltered load.
*
* The filter is built directly from VA parameters (param label === DC column,
* which VA↔DC matching already requires; numeric inferred from the VA param
* dataType/value) because DC column metadata isn't available until getdata
* has run. Any failure falls back to the normal load; the post-load replay
* (with full metadata) reconciles to the same filter signature.
*/
private async maybeDeferInitialVaLoad(): Promise<boolean> {
if (!this.isVaEmbed) return false
// Only the initial, unfiltered route — never when a FILTER_RK is present.
if (typeof this.route.snapshot.params['filterId'] !== 'undefined')
return false
const libds = this.route.snapshot.params['libMem']
if (!libds) return false
// VA posts its first message before Angular boots, so it's already in the
// external buffer by now — read it directly (no polling). If it hasn't
// arrived yet, skip the optimisation and let the normal load + live message
// apply the filter.
const msg = this.vaMessaging.latestMessage()
if (!msg) return false
const clauses = this.vaFilter.buildInitialClauses(msg)
if (clauses.length === 0) return false
try {
const res: any = await this.sasStoreService.saveQuery(libds, clauses)
const id = res?.result?.[0]?.FILTER_RK
const table = res?.result?.[0]?.FILTER_TABLE
if (id === undefined || table === undefined) return false
// Seed the cross-reload signature so the post-reload replay is a no-op.
this.vaMessaging.filterSignature = this.vaFilter.signature(clauses)
await this.reloadEditorRoute('/editor/' + table + '/' + id)
return true
} catch {
return false
}
}
ngAfterViewInit() {
// Fix ARIA accessibility issues after table initialization
setTimeout(() => {
@@ -2778,18 +2695,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
clearInterval(this.ariaCheckInterval)
this.ariaCheckInterval = undefined
}
// Cancel any pending debounced VA apply
if (this.vaDebounceTimer) {
clearTimeout(this.vaDebounceTimer)
this.vaDebounceTimer = undefined
}
// Remove the VA postMessage listener
if (this.vaUnsubscribe) {
this.vaUnsubscribe()
this.vaUnsubscribe = undefined
}
}
/**
@@ -2939,18 +2844,13 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
}
}
initSetup(response: EditorsGetDataServiceResponse, attempt = 0) {
if (this.getdataError) return
if (!response || !response.data) return
initSetup(response: EditorsGetDataServiceResponse) {
this.hotInstance = this.hotTableComponent!.hotInstance!
this.hotInstance = this.hotTableComponent?.hotInstance!
if (!this.hotInstance) {
// Retry init, don't permanently abort
if (attempt < 60) {
setTimeout(() => this.initSetup(response, attempt + 1), 50)
}
return
}
if (this.getdataError) return
if (!response) return
if (!response.data) return
if (!this.hotInstance) return
this.cols = response.data.cols
this.dsmeta = response.data.dsmeta
@@ -3506,234 +3406,5 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
setTimeout(() => {
this.fixAriaAccessibility()
}, 500)
// SAS Visual Analytics data-driven content mode: open editable immediately,
// re-apply any column visibility chosen by VA before this (filter) reload,
// and start receiving VA messages over the postMessage interface.
if (this.isVaEmbed) {
this.editTable()
if (this.vaMessaging.visibleColumns) {
this.applyVaColumnVisibility(new Set(this.vaMessaging.visibleColumns))
if (this.hotInstance) this.hotInstance.render()
}
// Listener is already registered early (ngOnInit) so VA's initial post is
// not missed; this is a guarded no-op if so. Now that the grid is ready,
// replay the buffered latest message to apply any filter/visibility that
// arrived during load.
this.subscribeToVaMessages()
// Reconcile to the true latest message after a reload — covers a message
// that arrived during the reload gap (caught by va-early, missed by the
// app listener). Signature dedup makes this a no-op if nothing changed.
this.handleVaMessage()
}
}
/**
* Subscribes to the SAS VA data-driven content postMessage channel. VA pushes
* a fresh message on every selection / parameter change. Registered early (in
* ngOnInit) so the initial message isn't missed; messages are buffered on the
* service and applied via a debounce so a burst of control clicks collapses
* into a single reload on the latest message.
*/
private subscribeToVaMessages() {
if (this.vaUnsubscribe) return
this.vaUnsubscribe = this.vaMessaging.onData(() => this.handleVaMessage())
}
/**
* Handles one VA message immediately (NOT debounced): updates column
* visibility and decides the filter. The cheap work (visibility, building the
* filter clauses, deciding pending) runs per message so the "pending"
* indicator can show the instant VA posts; only the expensive APPLY (saveQuery
* + reload) is debounced (live mode) via scheduleVaFilterApply.
*
* Filter data always comes from DC's getdata; VA only drives (a) column
* visibility and (b) the active filter (its params -> DC clauses, applied by
* reloading at /editor/<table>/<FILTER_RK>).
*/
private handleVaMessage() {
const msg = this.vaMessaging.latestMessage()
if (!msg || !this.libds || !this.cellValidation) return
// Grid not ready yet (cellValidation only has the delete column, or none):
// the label->var map would be empty, so every param would fail to map and
// produce an empty filter that wrongly CLEARS the active one — the infinite
// reload loop. Bail; the load-complete replay re-runs once metadata is in.
if (this.cellValidation.length <= 1) return
const labelToVar = this.vaFilter.buildLabelToVarMap(
this.cellValidation,
this.columnHeader
)
// Columns present in the message -> show these, hide the rest. A message
// with no data columns carries no column info, so leave visibility as-is
// rather than hiding everything.
const dataColumns = this.vaMessaging.dataColumns(msg)
let visibilityChanged = false
if (dataColumns.length > 0) {
const matchedVars = new Set<string>()
for (const { column } of dataColumns) {
const key = (column.label ?? column.name ?? '')
.toString()
.trim()
.toLowerCase()
const varName = labelToVar.get(key)
if (varName) matchedVars.add(varName)
}
this.vaMessaging.visibleColumns = Array.from(matchedVars)
visibilityChanged = this.applyVaColumnVisibility(matchedVars)
}
if (visibilityChanged && this.hotInstance) this.hotInstance.render()
this.updateVaFilterText(msg)
const clauses = this.vaFilter.buildClauses(msg, labelToVar, this.cols)
const signature = this.vaFilter.signature(clauses)
if (signature === this.vaMessaging.filterSignature) {
// Selection matches the applied filter — nothing pending. (Don't clear a
// 'loading' status; that belongs to an apply already in flight.)
if (this.vaFilterStatus === 'pending') this.vaFilterStatus = 'idle'
this.vaPendingClauses = null
return
}
// A real change arrived -> pending (shown in both modes, immediately).
this.vaPendingClauses = clauses
this.vaPendingSignature = signature
this.vaFilterStatus = 'pending'
// Live mode auto-advances after the debounce settles; confirm waits for the
// Apply button. The pending indicator stays visible throughout the debounce.
if (this.vaAutoApply) this.scheduleVaFilterApply()
}
/** Live mode: debounce a burst of VA changes into a single apply of the latest. */
private scheduleVaFilterApply() {
if (this.vaDebounceTimer) clearTimeout(this.vaDebounceTimer)
this.vaDebounceTimer = setTimeout(() => {
this.vaDebounceTimer = undefined
this.applyPendingVaFilter()
}, EditorComponent.VA_DEBOUNCE_MS)
}
/**
* Saves the VA-derived filter through DC's validatefilter flow and reloads the
* editor at /editor/<table>/<FILTER_RK> (preserving ?embed=va) so the filter
* key is visible in the URL. Empty clauses clear the filter.
*/
private async applyVaFilter(clauses: QueryClause[], signature: string) {
// A previous filter is still saving/reloading: drop this call WITHOUT
// advancing the signature. The reloaded editor's load-complete replays the
// latest buffered message, which re-evaluates against the still-accurate
// last-applied signature and applies whatever the newest selection is.
if (this.vaApplyingFilter) return
this.vaApplyingFilter = true
// Now actually fetching: surface the "loading filter" status (until DC's
// native "Loading Table" takes over after the reload navigates).
this.vaFilterStatus = 'loading'
// Record the signature only now that we're actually applying it.
this.vaMessaging.filterSignature = signature
try {
if (clauses.length === 0) {
// Already unfiltered (no FILTER_RK in route)? Do nothing — reloading
// would be a pointless round-trip and re-trigger the drain/replay cycle.
// Only reload to clear a filter that is currently applied.
if (typeof this.filter_pk === 'undefined') {
this.vaFilterStatus = 'idle'
return
}
await this.reloadEditorRoute('/editor/' + this.libds)
return
}
const res: any = await this.sasStoreService.saveQuery(
this.libds!,
clauses
)
const id = res?.result?.[0]?.FILTER_RK
const table = res?.result?.[0]?.FILTER_TABLE
if (id === undefined || table === undefined) {
this.vaFilterStatus = 'idle'
return
}
await this.reloadEditorRoute('/editor/' + table + '/' + id)
} catch {
// Keep the current view on failure; the next message can retry.
this.vaFilterStatus = 'idle'
} finally {
this.vaApplyingFilter = false
}
}
/** Apply the staged VA filter (the debounce in live mode, or the Apply button). */
public applyPendingVaFilter(): void {
if (!this.vaPendingClauses) return
const clauses = this.vaPendingClauses
const signature = this.vaPendingSignature
this.vaPendingClauses = null
void this.applyVaFilter(clauses, signature)
}
/**
* Toggles between live (auto-apply) and confirm filter modes. The choice is
* stored on globals so it survives editor reloads. Switching to live with a
* change still pending applies it immediately.
*/
public toggleVaAutoApply(): void {
globals.vaApplyMode = this.vaAutoApply ? 'confirm' : 'live'
if (this.vaAutoApply && this.vaFilterStatus === 'pending') {
this.applyPendingVaFilter()
}
}
/** Forces a full editor reload of `target`, preserving the embed query param. */
private async reloadEditorRoute(target: string) {
await this.router.navigate(['/'], {
skipLocationChange: true,
queryParamsHandling: 'preserve'
})
await this.router.navigate([target], { queryParamsHandling: 'preserve' })
}
/**
* Shows the DC data columns present in the latest VA message and hides the
* rest (primary-key and validator-hidden columns are always preserved). VA
* parameters can add/remove columns between pushes.
*/
private applyVaColumnVisibility(matchedVars: Set<string>): boolean {
const hot = this.hotInstance
if (!hot) return false
const plugin: any = hot.getPlugin('hiddenColumns')
if (!plugin) return false
const validatorHidden: number[] = this.dcValidator?.getHiddenColumns() || []
const toHide: number[] = []
const toShow: number[] = []
for (let i = 1; i < this.cellValidation.length; i++) {
const varName = this.cellValidation[i]?.data
if (!varName || this.isColPk(varName)) continue
if (validatorHidden.includes(i)) continue
if (matchedVars.has(varName)) {
if (plugin.isHidden(i)) toShow.push(i)
} else if (!plugin.isHidden(i)) {
toHide.push(i)
}
}
if (toShow.length) plugin.showColumns(toShow)
if (toHide.length) plugin.hideColumns(toHide)
return toShow.length > 0 || toHide.length > 0
}
/** Logs the active VA filter summary (kept out of the embedded grid UI). */
private updateVaFilterText(msg: VaMessage) {
const summary = (msg.parameters || [])
.filter((p) => p && p.name !== undefined && p.value !== undefined)
.map((p) => `${p.label ?? p.name}=${p.value}`)
.join(', ')
// eslint-disable-next-line no-console
console.log('VA filters active:', summary)
}
}
@@ -1,16 +1,6 @@
/**
* Model for a row of `dynamic_values` returned by the getdynamiccolvals
* service (the dropdown source for the edited cell itself).
*/
export interface DynamicCellValidation {
DISPLAY_INDEX: number
RAW_VALUE: string | number
}
/**
* Model for a row of `dynamic_extended_values` returned by the
* getdynamiccolvals service (dropdown values for _other_ cells in the _same_
* row, mapped via DISPLAY_INDEX).
* Model for the dynamic cell validation in the editor
* (sending whole row to the backend service and recieving data for the cell dropdown)
*/
export interface DynamicExtendedCellValidation {
DISPLAY_INDEX: number
@@ -18,5 +8,5 @@ export interface DynamicExtendedCellValidation {
EXTRA_COL_NAME: string
RAW_VALUE_CHAR: string
RAW_VALUE_NUM: number
FORCED_VALUE: number
FORCE_FLAG: number
}
@@ -1,14 +1,9 @@
import {
DynamicCellValidation,
DynamicExtendedCellValidation
} from '../editor/models/dynamicExtendedCellValidation'
export interface CellValidationSource {
col: number
row: number
strict: boolean
values: DynamicCellValidation[]
extended_values?: DynamicExtendedCellValidation[]
values: any[]
extended_values?: string[]
hash: string
count: number
pending?: Promise<void>
@@ -1,175 +0,0 @@
import { VaFilterService } from './va-filter.service'
import { VaMessage } from './va-messaging.service'
/**
* Minimal stand-in for HelperService.convertJsDateToSasDate using the real
* epoch math, so date/datetime assertions use the actual SAS internal values
* without pulling in HelperService's dependency graph (SasService etc.).
*/
class HelperServiceStub {
convertJsDateToSasDate(jsDate: Date, unit = 'days'): number {
const ms = Date.UTC(
jsDate.getFullYear(),
jsDate.getMonth(),
jsDate.getDate(),
jsDate.getHours(),
jsDate.getMinutes(),
jsDate.getSeconds()
)
const sasMs = ms + 315619200000
return unit === 'days'
? Math.floor(Math.abs(sasMs / 86400000))
: sasMs / 1000
}
}
function msg(parameters: any[]): VaMessage {
return {
resultName: 'r',
rowCount: 0,
data: [],
columns: [],
parameters
} as VaMessage
}
describe('VaFilterService', () => {
let service: VaFilterService
// DC column metadata used by the metadata path. cellValidation[0] is the
// delete column and must be skipped by buildLabelToVarMap.
const cellValidation = [
{ data: 'DEL' },
{ data: 'SOME_CHAR' },
{ data: 'SOME_NUM' },
{ data: 'SOME_TIME' },
{ data: 'SOME_DATE' },
{ data: 'SOME_DATETIME' }
]
const columnHeader = [
'Delete?',
'SOME_CHAR',
'SOME_NUM',
'SOME_TIME',
'SOME_DATE',
'SOME_DATETIME'
]
const cols = [
{ NAME: 'SOME_CHAR', 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))
})
})
})
@@ -1,266 +0,0 @@
import { Injectable } from '@angular/core'
import { HelperService } from './helper.service'
import { VaMessage } from './va-messaging.service'
import { QueryClause } from '../models/TableData'
/** SAS data-type kind used to format a VA filter value into a DC RAW_VALUE. */
type VaColumnKind = 'char' | 'numeric' | 'time' | 'date' | 'datetime'
/**
* Translates SAS Visual Analytics data-driven content `parameters` into DC
* filter clauses. Independent of editor/HOT state — the editor passes the
* loaded column metadata in — so it is unit-testable on its own. Temporal
* values are converted to SAS internal numbers (matching DC's native filters)
* via the shared HelperService.
*/
@Injectable({ providedIn: 'root' })
export class VaFilterService {
private static readonly SAS_MONTHS = [
'JAN',
'FEB',
'MAR',
'APR',
'MAY',
'JUN',
'JUL',
'AUG',
'SEP',
'OCT',
'NOV',
'DEC'
]
constructor(private helperService: HelperService) {}
/** label/name (lower-cased) -> DC variable (prop) name, skipping the delete col. */
buildLabelToVarMap(
cellValidation: any[],
columnHeader: string[]
): Map<string, string> {
const map = new Map<string, string>()
for (let i = 1; i < cellValidation.length; i++) {
const varName = cellValidation[i]?.data
if (!varName) continue
map.set(
(columnHeader[i] ?? varName).toString().trim().toLowerCase(),
varName
)
map.set(varName.toString().trim().toLowerCase(), varName)
}
return map
}
/**
* Translates VA `parameters` into DC filter clauses. Each parameter is matched
* to a DC column by label; a single value becomes `col = value`, multiple
* values become `col IN (...)`. Values are formatted for the column's SAS kind.
*/
buildClauses(
msg: VaMessage,
labelToVar: Map<string, string>,
cols: any[]
): QueryClause[] {
const clauses: QueryClause[] = []
for (const param of msg.parameters || []) {
if (!param || param.value === undefined || param.value === null) continue
const key = (param.label ?? param.name ?? '')
.toString()
.trim()
.toLowerCase()
// A VA param whose label isn't a DC column (beyond case, which is handled)
// simply doesn't map — skip it.
const varName = labelToVar.get(key)
if (!varName) continue
const 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)
)
}
}
@@ -1,102 +0,0 @@
import { VaMessagingService, VaMessage } from './va-messaging.service'
// Sample message taken from the official SAS DDC docs (brush column included).
const SAMPLE = {
version: '1',
resultName: 'dd40',
rowCount: 3,
availableRowCount: 3,
data: [
['Finch', 95000.0, 0.4285],
['Jones', 26000.0, 0.0],
['Smith', 108000.0, 0.5]
],
columns: [
{ name: 'bi184', label: 'dealer', type: 'string' },
{
name: 'bi258',
label: 'sales',
type: 'number',
usage: 'quantitative',
aggregation: 'sum'
},
{ name: 'ri1', type: 'number', usage: 'brush' }
]
}
describe('VaMessagingService', () => {
let service: VaMessagingService
beforeEach(() => {
service = new VaMessagingService()
})
describe('parseMessage', () => {
it('parses a valid DDC message and defaults parameters to []', () => {
const msg = service.parseMessage({ data: SAMPLE } as MessageEvent)
expect(msg).not.toBeNull()
expect(msg!.resultName).toBe('dd40')
expect(msg!.rowCount).toBe(3)
expect(msg!.parameters).toEqual([])
})
it('returns null for unrelated / malformed messages', () => {
expect(service.parseMessage({ data: null } as MessageEvent)).toBeNull()
expect(
service.parseMessage({ data: 'hello' } as unknown as MessageEvent)
).toBeNull()
expect(
service.parseMessage({ data: { columns: [] } } as MessageEvent)
).toBeNull()
})
})
describe('dataColumns', () => {
it('excludes brush columns and keeps original indices', () => {
const msg = service.parseMessage({ data: SAMPLE } as MessageEvent)!
const cols = service.dataColumns(msg)
expect(cols.map((c) => c.column.name)).toEqual(['bi184', 'bi258'])
expect(cols.map((c) => c.index)).toEqual([0, 1])
})
})
describe('onData', () => {
const sameOrigin = window.location.origin
it('invokes the callback for valid messages and unsubscribes cleanly', () => {
const received: VaMessage[] = []
const off = service.onData((m) => received.push(m))
window.dispatchEvent(
new MessageEvent('message', { data: SAMPLE, origin: sameOrigin })
)
expect(received.length).toBe(1)
expect(received[0].resultName).toBe('dd40')
// unrelated traffic is ignored
window.dispatchEvent(
new MessageEvent('message', { data: { foo: 1 }, origin: sameOrigin })
)
expect(received.length).toBe(1)
off()
window.dispatchEvent(
new MessageEvent('message', { data: SAMPLE, origin: sameOrigin })
)
expect(received.length).toBe(1)
})
it('rejects messages from an untrusted origin', () => {
const received: VaMessage[] = []
service.onData((m) => received.push(m))
window.dispatchEvent(
new MessageEvent('message', {
data: SAMPLE,
origin: 'https://evil.example.com'
})
)
expect(received.length).toBe(0)
})
})
})
@@ -1,218 +0,0 @@
import { Injectable } from '@angular/core'
/**
* SAS Visual Analytics data-driven content (DDC) postMessage interface.
*
* Contract (from the official SAS docs and the sample repo
* https://github.com/sassoftware/sas-visualanalytics-thirdpartyvisualizations):
* VA shares data with the data-driven content object via window.postMessage().
* The content registers a `message` listener; `event.data` is a JSON object.
* To act as the source of an action (selection / brushing) or to show an
* instructional message, the content posts a message back to `window.parent`,
* echoing the `resultName`.
*/
export interface VaColumn {
/** Internal VA column id, e.g. "bi184". */
name: string
/** Human label, e.g. "dealer". May be absent (e.g. on brush columns). */
label?: string
type: string
/** "categorical" | "quantitative" | "brush" | ... */
usage?: string
aggregation?: string
format?: any
}
export interface VaParameter {
/** VA internal id, e.g. "pr273". NOT the DC column. */
name: string
/** Human label — equals the DC column name in our setup, e.g. "some_dropdown". */
label?: string
/** "number" | "string" — VA's value type (used to decide numeric vs char). */
dataType?: string
type?: string
format?: any
value: any
}
export interface VaMessage {
version?: string
/** Required to echo back on any message to VA. */
resultName: string
rowCount: number
availableRowCount?: number
/** Row-major 2-D array; cell order matches `columns`. Measures are unformatted. */
data: any[][]
columns: VaColumn[]
/** Only the parameters used by the query are returned. */
parameters: VaParameter[]
}
@Injectable({ providedIn: 'root' })
export class VaMessagingService {
/** Result name from the most recent message, needed for the back-channel. */
private resultName: string | null = null
/**
* Origin of the last trusted inbound message — used as the explicit
* targetOrigin for the back-channel so payloads are never broadcast to an
* unintended parent.
*/
private parentOrigin: string | null = null
/**
* Validates that a message comes from a trusted source: our hosting frame
* (the normal SAS VA data-driven content case) or the same origin (local dev /
* tests). Untrusted messages — e.g. from an unrelated window or a malicious
* iframe — are rejected.
*/
private isTrustedSource(event: MessageEvent): boolean {
if (window.parent !== window && event.source === window.parent) return true
return !!event.origin && event.origin === window.location.origin
}
/**
* Cross-reload state. Applying a VA filter navigates to
* /editor/<table>/<FILTER_RK>, which recreates the editor component, so the
* desired filter + column visibility are held on this root singleton and
* re-applied after the reload.
*/
filterSignature: string | null = null
/** DC variable names that should be visible (the rest are hidden). */
visibleColumns: string[] | null = null
/** True once the pre-bootstrap early buffer has been drained (drain-once). */
private earlyDrained = false
/**
* Registers a window `message` listener and invokes `callback` for every
* valid DDC message. The latest message itself is read via `latestMessage()`
* from the always-on `window.__vaLastMessage` buffer, not stored here.
* Returns an unsubscribe function that removes the listener.
*/
onData(callback: (msg: VaMessage) => void): () => void {
const handler = (event: MessageEvent) => {
if (!this.isTrustedSource(event)) return
const parsed = this.parseMessage(event)
if (parsed) {
this.resultName = parsed.resultName
if (event.origin) this.parentOrigin = event.origin
callback(parsed)
}
}
window.addEventListener('message', handler, false)
// Replay any DDC message captured by the early listener before
// the app (and this listener) existed. VA pushes its message once on iframe
// load — which happens before Angular bootstraps — so without this the very
// first message is lost entirely and the editor loads unfiltered.
this.replayEarlyMessages(callback)
return () => window.removeEventListener('message', handler, false)
}
/**
* The true latest DDC message, from the always-on `window.__vaLastMessage`
* buffer (set in va-early.js). That listener is registered at page parse and
* never removed, so it has NO gaps — including the window during an editor
* reload when the app's own listener is briefly absent. So a message that
* arrives mid-reload (e.g. a rapid filter change) is still here.
*/
latestMessage(): VaMessage | null {
const captured = (window as unknown as { __vaLastMessage?: any })
.__vaLastMessage
return captured ? this.parseData(captured.data) : null
}
/**
* Replays the latest VA message captured by the early `window.__vaLastMessage`
* listener (set up in va-early.js), closing the pre-bootstrap race. Only runs
* when actually framed (DDC context).
*/
private replayEarlyMessages(callback: (msg: VaMessage) => void): void {
if (window.parent === window) return
// Drain only once per page lifetime. This service is a root singleton, so
// the flag persists across editor reloads — without it every reload would
// re-replay the captured message and fight the live filter (loop).
if (this.earlyDrained) return
this.earlyDrained = true
const captured = (window as unknown as { __vaLastMessage?: any })
.__vaLastMessage
const parsed = this.parseData(captured && captured.data)
if (!parsed) return
this.resultName = parsed.resultName
if (captured.origin) this.parentOrigin = captured.origin
callback(parsed)
}
/**
* Parses a raw window MessageEvent into a VaMessage, or null when it is not a
* recognisable DDC message (e.g. unrelated postMessage traffic).
*/
parseMessage(event: MessageEvent): VaMessage | null {
return this.parseData(event && event.data)
}
/**
* Parses a raw message payload (from a MessageEvent or the early buffer) into
* a VaMessage, or null when it is not a recognisable DDC message.
*/
private parseData(data: any): VaMessage | null {
if (!data || typeof data !== 'object') return null
if (typeof data.resultName !== 'string') return null
if (!Array.isArray(data.columns) || !Array.isArray(data.data)) return null
return {
version: data.version,
resultName: data.resultName,
rowCount: data.rowCount,
availableRowCount: data.availableRowCount,
data: data.data,
columns: data.columns,
parameters: Array.isArray(data.parameters) ? data.parameters : []
}
}
/**
* Columns that carry grid data — excludes `usage: "brush"` columns, which
* encode linked-selection state rather than data.
*/
dataColumns(msg: VaMessage): { column: VaColumn; index: number }[] {
return msg.columns
.map((column, index) => ({ column, index }))
.filter(({ column }) => column.usage !== 'brush')
}
/**
* Back-channel (DDC → VA) — selection / source-of-action. Designed per the
* SAS contract; not wired into the UI in v1.
*/
sendSelection(rows: number[]): void {
if (this.resultName === null) return
this.sendMessage({
resultName: this.resultName,
selections: rows.map((row) => ({ row }))
})
}
/**
* Back-channel (DDC → VA) — custom instructional message, shown in the report
* designer when the assigned data does not meet the visual's requirements.
*/
sendInstructionalMessage(message: string): void {
if (this.resultName === null) return
this.sendMessage({ resultName: this.resultName, message })
}
private sendMessage(message: any): void {
// Prefer the validated origin captured from the inbound VA message; only
// fall back to the referrer/own location when we have not yet received one.
const target =
this.parentOrigin ||
(window.location !== window.parent.location
? document.referrer
: document.location.href)
if (!target) return
window.parent.postMessage(message, target)
}
}
@@ -1,26 +1,23 @@
import Handsontable from 'handsontable'
import { datetimeValidator } from '../validations/hot-custom-validators'
import { DATETIME_FORMAT } from '../utils/mapIntlCellTypes'
/**
* Cell types for the date/time/datetime columns after the HOT 17 migration.
*
* All three DISPLAY the raw stored ISO string verbatim — `YYYY-MM-DD`,
* `HH:mm:ss`, `YYYY-MM-DD HH:mm:ss` — via an identity `valueFormatter`, so the
* cell text is stable and locale-independent. The native picker editors still
* follow the OS regional format while open; only the painted cell is ISO.
* The stored/submitted value is unchanged.
* `date` and `time` use Handsontable's BUILT-IN `intl-date` / `intl-time` cell
* types as-is — a native (browser/OS) picker editor plus the Intl renderer that
* formats the displayed value per the cell's `locale` + `dateFormat`/`timeFormat`
* (set as cell meta in mapIntlCellTypes). They are intentionally NOT re-registered
* here, so their localizing `valueFormatter` stays active. The stored/submitted
* value remains the raw ISO string; only the display is localized.
*
* `intl-date` and `intl-time` are HOT built-ins; we re-register them by spreading
* the built-in definition (keeping its native picker editor + ISO validator) and
* swapping only the `valueFormatter` for the identity one. `intl-datetime` has no
* built-in — it pairs a native `datetime-local` picker with the identity
* formatter. valueFormatter is applied render-only (tableView), so copy/paste and
* the stored value are untouched.
* Only `intl-datetime` needs registering — HOT has no built-in datetime type. It
* pairs a native `datetime-local` picker with a `valueFormatter` that formats the
* stored `YYYY-MM-DD HH:mm:ss` per the cell's locale (mirroring how the built-in
* intl-date/intl-time types localize, via tableView's `renderer.valueFormatter`).
*/
/** Identity formatter — display the stored ISO string as-is (no localization). */
const isoPassthrough = (value: any): any => value
const SPACE_ISO = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}(?::\d{2})?)$/
const BaseTextEditor = Handsontable.editors.getEditor('text') as any
@@ -66,34 +63,39 @@ class IntlDatetimeEditor extends BaseTextEditor {
}
/**
* Re-registers a built-in HOT cell type (`intl-date` / `intl-time`) with only its
* `valueFormatter` swapped for the ISO passthrough — the native picker editor and
* ISO validator are reused from the built-in definition. Idempotent: a second
* call reads back our already-overridden type, which still carries the original
* editor/validator.
* Localizes a stored `YYYY-MM-DD HH:mm:ss` datetime per the cell's locale.
* Invoked by Handsontable via `renderer.valueFormatter` (tableView) before the
* text renderer paints the cell, so the stored value is never mutated. Falls
* back to the raw value when it is empty or unparseable.
*/
const overrideWithPassthrough = (type: string) => {
const builtin = Handsontable.cellTypes.getCellType(type) as any
Handsontable.cellTypes.registerCellType(type, {
...builtin,
valueFormatter: isoPassthrough
})
function intlDatetimeValueFormatter(value: any, cellProperties: any): any {
if (typeof value !== 'string') return value
const match = value.match(SPACE_ISO)
if (!match) return value
const date = new Date(`${match[1]}T${match[2]}`)
if (isNaN(date.getTime())) return value
const options: Intl.DateTimeFormatOptions =
cellProperties.datetimeFormat || DATETIME_FORMAT
const locale = cellProperties.locale || window.navigator.language
return new Intl.DateTimeFormat(locale, options).format(date)
}
/**
* Registers the Intl date/time/datetime cell types. Idempotent — safe to call
* per DcValidator construction. All three display the stored ISO value verbatim.
* Registers the `intl-datetime` cell type. Idempotent — safe to call per
* DcValidator construction. `intl-date`/`intl-time` are not registered here;
* the built-in HOT types are used directly.
*/
export const registerIntlCellTypes = () => {
overrideWithPassthrough('intl-date')
overrideWithPassthrough('intl-time')
Handsontable.cellTypes.registerCellType('intl-datetime', {
// IntlDatetimeEditor extends the (loosely-typed) text editor returned by
// getEditor('text'); cast to satisfy registerCellType's editor signature.
editor: IntlDatetimeEditor as any,
renderer: baseTextRenderer,
validator: datetimeValidator,
valueFormatter: isoPassthrough
valueFormatter: intlDatetimeValueFormatter
} as any)
}
@@ -35,21 +35,26 @@ describe('DC Validator', () => {
expect(someNumRule?.allowEmpty).toBeFalse()
// SAS sends semantic date/time/datetime column types; the frontend maps them
// to the HOT 17 Intl cell-type names (see mapIntlCellTypes). The cell display
// is the raw stored ISO string (no locale/format meta) — see
// registerIntlCellTypes. The deprecated correctFormat + moment format strings
// are no longer sent in the payload.
// to the HOT 17 Intl cell types and attaches locale + Intl format meta so the
// cell display is localized (see mapIntlCellTypes). The deprecated
// correctFormat + moment format strings are no longer sent in the payload.
const someDateRule = dcValidator.getRule('SOME_DATE')
expect(someDateRule?.correctFormat).toBeUndefined()
expect(someDateRule?.type).toBe('intl-date')
expect(someDateRule?.dateFormat).toBeDefined()
expect((someDateRule as any)?.locale).toBeTruthy()
const someDatetimeRule = dcValidator.getRule('SOME_DATETIME')
expect(someDatetimeRule?.type).toBe('intl-datetime')
expect(someDatetimeRule?.correctFormat).toBeUndefined()
expect((someDatetimeRule as any)?.datetimeFormat).toBeDefined()
expect((someDatetimeRule as any)?.locale).toBeTruthy()
const someTimeRule = dcValidator.getRule('SOME_TIME')
expect(someTimeRule?.type).toBe('intl-time')
expect(someTimeRule?.correctFormat).toBeUndefined()
expect((someTimeRule as any)?.timeFormat).toBeDefined()
expect((someTimeRule as any)?.locale).toBeTruthy()
// Get col with dropdown, check if merging sas data is correct
const someDropdownRule = dcValidator.getRule('SOME_DROPDOWN')
@@ -2,31 +2,58 @@ import { DcValidation } from '../models/dc-validation.model'
/**
* Maps SAS' semantic date/time/datetime column types to the HOT 17 Intl cell
* type names. SAS sends a HOT-agnostic flag (`date`/`time`/`datetime`); the
* frontend owns the HOT-specific cell-type names:
* types and attaches the locale + Intl format meta that drive their display.
*
* - `date` → `intl-date` (native date picker)
* - `time` → `intl-time` (native time picker)
* - `datetime` → `intl-datetime` (native datetime-local picker)
* SAS sends a HOT-agnostic flag (`date`/`time`/`datetime`); the frontend owns
* the HOT-specific cell-type names and the locale-aware rendering:
*
* The cell display is the raw stored ISO string (locale-independent) — see
* registerIntlCellTypes, where these types' `valueFormatter` is the identity
* passthrough. No locale/format meta is attached; it is unused for display, and
* the validators are ISO-based and locale-agnostic. Runs in the DcValidator
* constructor, before the rules reach Handsontable.
* - `date` → `intl-date` (native date picker; display via Intl `dateFormat`)
* - `time` → `intl-time` (native time picker; display via Intl `timeFormat`)
* - `datetime` → `intl-datetime` (native datetime-local picker; display via `datetimeFormat`)
*
* The cell display is localized to `navigator.language` (the browser/OS locale);
* the stored/submitted value stays the raw ISO string. Second precision is kept
* for time/datetime so SAS time/datetime values are not truncated. Runs in the
* DcValidator constructor, before the rules reach Handsontable. See
* registerIntlCellTypes for the editors/validators these resolve to.
*
* @param rules Cell validation rules passed to Handsontable's `columns`.
*/
const INTL_TYPE_MAP: Record<string, string> = {
date: 'intl-date',
time: 'intl-time',
datetime: 'intl-datetime'
const DATE_FORMAT: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: '2-digit',
day: '2-digit'
}
const TIME_FORMAT: Intl.DateTimeFormatOptions = {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}
export const DATETIME_FORMAT: Intl.DateTimeFormatOptions = {
...DATE_FORMAT,
...TIME_FORMAT
}
const INTL_TYPE_MAP: Record<
string,
{ type: string; meta: Record<string, unknown> }
> = {
date: { type: 'intl-date', meta: { dateFormat: DATE_FORMAT } },
time: { type: 'intl-time', meta: { timeFormat: TIME_FORMAT } },
datetime: { type: 'intl-datetime', meta: { datetimeFormat: DATETIME_FORMAT } }
}
export const mapIntlCellTypes = (rules: DcValidation[]): DcValidation[] => {
for (const rule of rules) {
const mapped = rule.type ? INTL_TYPE_MAP[rule.type as string] : undefined
if (mapped) rule.type = mapped
const cfg = rule.type ? INTL_TYPE_MAP[rule.type as string] : undefined
if (!cfg) continue
rule.type = cfg.type
const meta = rule as Record<string, unknown>
meta.locale = window.navigator.language
Object.assign(meta, cfg.meta)
}
return rules
@@ -1,26 +0,0 @@
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)
})
})
@@ -1,22 +0,0 @@
/**
* 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'
}
-47
View File
@@ -1,47 +0,0 @@
/*
* SAS Visual Analytics data-driven content: earliest-possible message capture.
*
* Loaded as an external script from index.html (CSP-safe under a strict policy
* — no inline script needed) and executed at HTML parse time, long before the
* Angular app bootstraps. SAS VA posts its DDC data message once on iframe
* load, which happens before Angular is up, so without this the very first
* message (and any pre-set filter it carries) is lost.
*
* The latest captured message is stored on window.__vaLastMessage;
* VaMessagingService reads it when it subscribes (closing the startup race) and
* to recover the latest message across editor reloads.
*
* Self-guards on ?embed=va so it is completely inert in normal (non-VA) use.
*/
;(function () {
'use strict'
try {
var hash = window.location.hash || ''
var query = hash.indexOf('?') >= 0 ? hash.split('?')[1] : ''
if (new URLSearchParams(query).get('embed') !== 'va') return
} catch (e) {
return
}
window.addEventListener(
'message',
function (event) {
var data = event && event.data
if (
data &&
typeof data === 'object' &&
typeof data.resultName === 'string'
) {
// Store ONLY the latest message — readers just need the most recent, so
// a single value keeps this bounded across a long session of filters.
window.__vaLastMessage = {
t: Date.now(),
origin: event.origin,
data: data
}
}
},
false
)
})()
-9
View File
@@ -5,15 +5,6 @@
<title>Data Controller</title>
<!-- <base href="/"> -->
<!--
SAS VA data-driven content: earliest-possible message capture runs before
Angular bootstraps. It is the `va-early` global script bundled via
angular.json (src/assets/va-early.js) and auto-injected here at build time
— external (not inline) so it passes a strict CSP, and it self-guards on
?embed=va so it is inert in normal use. See
VaMessagingService.replayEarlyMessages.
-->
<!-- meta tags -->
<meta name="description" content="Capture, Review, and Approve" />
<meta itemprop="name" content="Data Controller for SAS®" />
+20 -3
View File
@@ -1,12 +1,12 @@
{
"name": "dcfrontend",
"version": "7.8.2",
"version": "7.7.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dcfrontend",
"version": "7.8.2",
"version": "7.7.2",
"hasInstallScript": true,
"devDependencies": {
"@saithodev/semantic-release-gitea": "^2.1.0",
@@ -15,7 +15,8 @@
"@semantic-release/git": "^10.0.1",
"@semantic-release/npm": "13.1.5",
"@semantic-release/release-notes-generator": "14.1.0",
"commit-and-tag-version": "12.7.1"
"commit-and-tag-version": "12.7.1",
"prettier": "^3.7.4"
}
},
"node_modules/@actions/core": {
@@ -5961,6 +5962,22 @@
"node": ">=0.10.0"
}
},
"node_modules/prettier": {
"version": "3.7.4",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz",
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-ms": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz",
+8 -7
View File
@@ -1,6 +1,6 @@
{
"name": "dcfrontend",
"version": "7.9.1",
"version": "7.8.2",
"description": "Data Controller",
"devDependencies": {
"@saithodev/semantic-release-gitea": "^2.1.0",
@@ -9,7 +9,8 @@
"@semantic-release/git": "^10.0.1",
"@semantic-release/npm": "13.1.5",
"@semantic-release/release-notes-generator": "14.1.0",
"commit-and-tag-version": "12.7.1"
"commit-and-tag-version": "12.7.1",
"prettier": "^3.7.4"
},
"overrides": {
"got": "11.8.6"
@@ -18,11 +19,11 @@
"install": "cd client && npm i && cd ../sas && npm i",
"build-frontend": "cd client && npm run build",
"release": "commit-and-tag-version",
"lint": "cd client && npm run lint",
"lint:fix": "cd client && npm run lint:fix",
"lint:fix:silent": "cd client && npm run lint:fix:silent",
"lint:check": "cd client && npm run lint:check",
"lint:check:silent": "cd client && npm run lint:check:silent",
"lint": "npm run lint:fix",
"lint:fix": "npx prettier --write \"client/{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"client/cypress/e2e/*.cy.ts\"",
"lint:fix:silent": "npx prettier --log-level silent --write \"client/{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"client/cypress/e2e/*.cy.ts\"",
"lint:check": "npx prettier --check \"client/{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"client/cypress/e2e/*.cy.ts\"",
"lint:check:silent": "npx prettier --log-level silent --check \"client/{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"client/cypress/e2e/*.cy.ts\"",
"jo": "echo",
"prepare": "git rev-parse --git-dir && git config core.hooksPath ./.git-hooks && git config core.autocrlf false || true"
},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+38 -26
View File
@@ -6,8 +6,8 @@
"": {
"name": "dc-sas",
"dependencies": {
"@sasjs/cli": "4.18.0",
"@sasjs/core": "4.67.3"
"@sasjs/cli": "4.17.4",
"@sasjs/core": "4.67.1"
}
},
"node_modules/@asamuzakjp/css-color": {
@@ -124,7 +124,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -147,7 +146,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -202,15 +200,15 @@
}
},
"node_modules/@sasjs/adapter": {
"version": "4.17.1",
"resolved": "https://registry.npmjs.org/@sasjs/adapter/-/adapter-4.17.1.tgz",
"integrity": "sha512-MxTvAoVnr+sBmKtB+CHxDiwvsqfzBqQ3BT/Q2Of6g7WWzJRBvpJcpf6C9R80Uq/cxvEie9JAM3EfExtFEYlcLg==",
"version": "4.16.7",
"resolved": "https://registry.npmjs.org/@sasjs/adapter/-/adapter-4.16.7.tgz",
"integrity": "sha512-pOKAhOPijr663PFWGx+JEcWaMOGwICMA0w8LeXHPHQwvbHVGPCJKV5QroGqrjTO62HlhsHzow4Vb+DjIui1jgQ==",
"license": "ISC",
"dependencies": {
"@sasjs/utils": "^3.5.6",
"axios": "1.16.0",
"axios-cookiejar-support": "5.0.5",
"form-data": "4.0.6",
"form-data": "4.0.4",
"https": "1.0.0",
"tough-cookie": "4.1.3"
}
@@ -251,13 +249,13 @@
}
},
"node_modules/@sasjs/cli": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@sasjs/cli/-/cli-4.18.0.tgz",
"integrity": "sha512-QFsLS9XXF64x9w51kYkmx3ZUWCA9ycVDeLIaNJpHcaTu6jgPRAVHQ61ShXBKlYp/Vrze9pNapW43iaXt7o6VEA==",
"version": "4.17.4",
"resolved": "https://registry.npmjs.org/@sasjs/cli/-/cli-4.17.4.tgz",
"integrity": "sha512-lNkIy6sojgT5mRQ/tkh8lyrIxNhrREw+CTkfEmy+mDiOh9COfGWMoSpBjRn2iqHX+dwOKaUlq6UwU2zHDLOmqQ==",
"license": "ISC",
"dependencies": {
"@sasjs/adapter": "4.17.1",
"@sasjs/core": "4.67.3",
"@sasjs/adapter": "4.16.7",
"@sasjs/core": "4.67.1",
"@sasjs/lint": "2.4.3",
"@sasjs/utils": "3.5.8",
"adm-zip": "0.5.10",
@@ -317,9 +315,9 @@
}
},
"node_modules/@sasjs/core": {
"version": "4.67.3",
"resolved": "https://registry.npmjs.org/@sasjs/core/-/core-4.67.3.tgz",
"integrity": "sha512-7s4UeVN4slF1URo1C5/UnNVv+TonUg+vBktFRiFhcbFYr3RnfXyClBPED6nQOF8AFd2xdXEss58hsCeIebr4lg==",
"version": "4.67.1",
"resolved": "https://registry.npmjs.org/@sasjs/core/-/core-4.67.1.tgz",
"integrity": "sha512-yb4xW8JxsWWY3ZTN4yj/mP8ZasPIHSQVjHESLui3xS0HNatBw96H9ZYGJO3LcrkDx+X5xJz45f6sdHAJYxVeSA==",
"license": "MIT"
},
"node_modules/@sasjs/lint": {
@@ -466,7 +464,6 @@
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
"license": "MIT",
"peer": true,
"dependencies": {
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
@@ -492,6 +489,22 @@
"tough-cookie": ">=4.0.0"
}
},
"node_modules/axios/node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -1018,16 +1031,16 @@
}
},
"node_modules/form-data": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
@@ -1181,9 +1194,9 @@
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -2156,7 +2169,6 @@
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz",
"integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==",
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"psl": "^1.1.33",
"punycode": "^2.1.1",
+2 -2
View File
@@ -28,7 +28,7 @@
},
"private": true,
"dependencies": {
"@sasjs/cli": "4.18.0",
"@sasjs/core": "4.67.3"
"@sasjs/cli": "4.17.4",
"@sasjs/core": "4.67.1"
}
}
@@ -1,54 +0,0 @@
/**
@file
@brief migration script to move to v7.9 of Data Controller
OPTIONAL CHANGE - upload additional validation rules in dropdown (data only)
**/
%let dclib=YOURDCLIB;
libname &dclib "/YOUR/DATACONTROLLER/LIBRARY/PATH";
/* add new validation rules */
proc sql noprint;
select max(selectbox_rk) into: maxrk
from &dclib..mpe_selectbox;
insert into &dclib..mpe_selectbox set
selectbox_rk=&maxrk+1
,ver_from_dttm=0
,select_lib="&dclib"
,select_ds="MPE_VALIDATIONS"
,base_column="RULE_TYPE"
,selectbox_value='NUMBER_FORMAT'
,selectbox_order=9
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &dclib..mpe_selectbox set
selectbox_rk=&maxrk+2
,ver_from_dttm=0
,select_lib="&dclib"
,select_ds="MPE_VALIDATIONS"
,base_column="RULE_TYPE"
,selectbox_value='READONLY'
,selectbox_order=10
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &dclib..mpe_selectbox set
selectbox_rk=&maxrk+3
,ver_from_dttm=0
,select_lib="&dclib"
,select_ds="MPE_VALIDATIONS"
,base_column="RULE_TYPE"
,selectbox_value='HIDDEN'
,selectbox_order=11
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &dclib..mpe_selectbox set
selectbox_rk=&maxrk+4
,ver_from_dttm=0
,select_lib="&dclib"
,select_ds="MPE_VALIDATIONS"
,base_column="RULE_TYPE"
,selectbox_value='ROUND'
,selectbox_order=12
,ver_to_dttm='31DEC5999:23:59:59'dt;
+7 -43
View File
@@ -823,7 +823,7 @@ insert into &lib..mpe_selectbox set
,select_lib="&lib"
,select_ds="MPE_VALIDATIONS"
,base_column="RULE_TYPE"
,selectbox_value="HARDSELECT"
,selectbox_value="MINVAL"
,selectbox_order=2
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &lib..mpe_selectbox set
@@ -832,7 +832,7 @@ insert into &lib..mpe_selectbox set
,select_lib="&lib"
,select_ds="MPE_VALIDATIONS"
,base_column="RULE_TYPE"
,selectbox_value="HARDSELECT_HOOK"
,selectbox_value="MAXVAL"
,selectbox_order=3
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &lib..mpe_selectbox set
@@ -841,7 +841,7 @@ insert into &lib..mpe_selectbox set
,select_lib="&lib"
,select_ds="MPE_VALIDATIONS"
,base_column="RULE_TYPE"
,selectbox_value="HIDDEN"
,selectbox_value="NOTNULL"
,selectbox_order=4
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &lib..mpe_selectbox set
@@ -850,7 +850,7 @@ insert into &lib..mpe_selectbox set
,select_lib="&lib"
,select_ds="MPE_VALIDATIONS"
,base_column="RULE_TYPE"
,selectbox_value="MAXVAL"
,selectbox_value="HARDSELECT"
,selectbox_order=5
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &lib..mpe_selectbox set
@@ -859,7 +859,7 @@ insert into &lib..mpe_selectbox set
,select_lib="&lib"
,select_ds="MPE_VALIDATIONS"
,base_column="RULE_TYPE"
,selectbox_value="MINVAL"
,selectbox_value="HARDSELECT_HOOK"
,selectbox_order=6
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &lib..mpe_selectbox set
@@ -868,45 +868,9 @@ insert into &lib..mpe_selectbox set
,select_lib="&lib"
,select_ds="MPE_VALIDATIONS"
,base_column="RULE_TYPE"
,selectbox_value="NOTNULL"
,selectbox_value="SOFTSELECT"
,selectbox_order=7
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &lib..mpe_selectbox set
selectbox_rk=%mf_increment(rk)
,ver_from_dttm=0
,select_lib="&lib"
,select_ds="MPE_VALIDATIONS"
,base_column="RULE_TYPE"
,selectbox_value="NUMBER_FORMAT"
,selectbox_order=8
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &lib..mpe_selectbox set
selectbox_rk=%mf_increment(rk)
,ver_from_dttm=0
,select_lib="&lib"
,select_ds="MPE_VALIDATIONS"
,base_column="RULE_TYPE"
,selectbox_value="READONLY"
,selectbox_order=9
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &lib..mpe_selectbox set
selectbox_rk=%mf_increment(rk)
,ver_from_dttm=0
,select_lib="&lib"
,select_ds="MPE_VALIDATIONS"
,base_column="RULE_TYPE"
,selectbox_value="ROUND"
,selectbox_order=10
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &lib..mpe_selectbox set
selectbox_rk=%mf_increment(rk)
,ver_from_dttm=0
,select_lib="&lib"
,select_ds="MPE_VALIDATIONS"
,base_column="RULE_TYPE"
,selectbox_value="SOFTSELECT"
,selectbox_order=11
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &lib..mpe_selectbox set
selectbox_rk=%mf_increment(rk)
,ver_from_dttm=0
@@ -914,7 +878,7 @@ insert into &lib..mpe_selectbox set
,select_ds="MPE_VALIDATIONS"
,base_column="RULE_TYPE"
,selectbox_value="SOFTSELECT_HOOK"
,selectbox_order=12
,selectbox_order=8
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &lib..mpe_selectbox set
selectbox_rk=%mf_increment(rk)
+4 -3
View File
@@ -270,14 +270,15 @@ data _null_;
file vldtr ;
put 'proc sql;';
put 'create table work.vals as';
put ' select distinct ORIGIN as raw_value';
put ' select distinct ORIGIN as display_value,';
put ' ORIGIN as raw_value';
put " from &demolib..COUNTRIES";
put ' order by 1;';
put 'data work.DYNAMIC_VALUES; set work.vals;display_index=_n_;run;';
put ' ';
put 'proc sql;';
put 'create table work.dev as ';
put ' select a.display_index, a.raw_value';
put ' select a.display_index,b.country as display_value';
put ' from work.DYNAMIC_VALUES as a';
put " left join &demolib..countries as b";
put " on a.raw_value=b.origin";
@@ -285,7 +286,7 @@ data _null_;
put 'data work.DYNAMIC_EXTENDED_VALUES; set work.dev;by display_index;';
put ' EXTRA_COL_NAME="COUNTRY";';
put ' DISPLAY_TYPE="C";';
put ' RAW_VALUE_CHAR=raw_value;';
put ' RAW_VALUE_CHAR=DISPLAY_VALUE;';
put ' RAW_VALUE_NUM=.;';
put ' if first.display_index then forced_value=1;';
put 'run;';
@@ -22,7 +22,8 @@
<h5>DYNAMIC_VALUES</h5>
The RAW_VALUE column may be charactor or numeric. If DISPLAY_INDEX is not
provided, it is added automatically.
provided, it is added automatically. A DISPLAY_VALUE column may be supplied
by the hook but is ignored - only DISPLAY_INDEX and RAW_VALUE are returned.
|DISPLAY_INDEX:best.|RAW_VALUE|
|---|---|
@@ -37,6 +38,9 @@
Should be used sparingly! The use of large tables here can slow down the
browser.
A DISPLAY_VALUE column may be supplied by the hook but is ignored - it is not
returned to the client.
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|
|---|---|---|
|1|DISCOUNT_RT|N|0.5||
@@ -167,26 +171,29 @@ run;
%mp_include(sascode)
%mp_abort(mode=INCLUDE)
/* ensure that the DISPLAY_INDEX variable exists. */
/* ensure that the DISPLAY_INDEX variable exists. DISPLAY_VALUE is accepted
from the hook for backwards compatibility but is no longer sent to the client */
data work.dynamic_values;
length DISPLAY_INDEX 8;
length DISPLAY_INDEX 8 DISPLAY_VALUE $32767;
if _n_=1 then call missing(of _all_);
set work.dynamic_values;
display_index=coalesce(display_index,_n_);
keep DISPLAY_INDEX RAW_VALUE;
run;
/* ensure that work.dynamic_extended_values exists with correct types.*/
/* ensure that work.dynamic_extended_values exists with correct types.
DISPLAY_VALUE is dropped - it is not consumed by the client */
data work.dynamic_extended_values;
length DISPLAY_INDEX 8 EXTRA_COL_NAME $32 DISPLAY_TYPE $1
length DISPLAY_INDEX 8 EXTRA_COL_NAME $32 DISPLAY_VALUE $5000 DISPLAY_TYPE $1
RAW_VALUE_NUM 8 RAW_VALUE_CHAR $5000 FORCED_VALUE 8;
if _n_=1 then call missing(of _all_);
set work.dynamic_extended_values;
drop DISPLAY_VALUE;
run;
%webout(OPEN)
%webout(OBJ,dynamic_values,fmt=N)
%webout(OBJ,dynamic_extended_values,fmt=N)
%webout(ARR,dynamic_values,fmt=N)
%webout(ARR,dynamic_extended_values,fmt=N)
%webout(CLOSE)
%mpeterm()
@@ -48,7 +48,7 @@ run;
and the two array elements (DISPLAY_INDEX, RAW_VALUE)
*/
%mp_assertcols(work.DYNAMIC_VALUES,
cols=display_index raw_value,
cols=element1 element2,
test=ALL,
desc=Check two columns exist in DYNAMIC_VALUES
)
@@ -93,7 +93,7 @@ run;
data work.check;
val="&dclib";
run;
%mp_assertcolvals(work.dynamic_values.raw_value,
%mp_assertcolvals(work.dynamic_values.element2,
checkvals=work.check.val,
desc=DCLIB found in getdynamicgolvals RAW_VALUE response,
test=ANYVAL
@@ -22,10 +22,10 @@
The RAW_VALUE column may be charactor or numeric. If DISPLAY_INDEX is not
provided, it is added automatically.
|DISPLAY_INDEX:best.|RAW_VALUE|
|---|---|
|1|77.43|
|2|88.43|
|DISPLAY_INDEX:best.|DISPLAY_VALUE:$|RAW_VALUE|
|---|---|---|
|1|$77.43|77.43|
|2|$88.43|88.43|
<h5>DYNAMIC_EXTENDED_VALUES</h5>
This table is optional. If provided, it will map the DISPLAY_INDEX from the
@@ -35,17 +35,17 @@
Should be used sparingly! The use of large tables here can slow down the
browser.
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_VALUE:$|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|
|---|---|---|
|1|DISCOUNT_RT|N|0.5||
|1|DISCOUNT_RT|N|0.4||
|1|DISCOUNT_RT|N|0.3||
|1|CURRENCY_SYMBOL|C||"GBP"|
|1|CURRENCY_SYMBOL|C||"RSD"|
|2|DISCOUNT_RT|N|0.5||
|2|DISCOUNT_RT|N|0.4||
|2|CURRENCY_SYMBOL|C||"EUR"|
|2|CURRENCY_SYMBOL|C||"HKD"|
|1|DISCOUNT_RT|"50%"|N|0.5||
|1|DISCOUNT_RT|"40%"|N|0.4||
|1|DISCOUNT_RT|"30%"|N|0.3||
|1|CURRENCY_SYMBOL|"GBP"|C||"GBP"|
|1|CURRENCY_SYMBOL|"RSD"|C||"RSD"|
|2|DISCOUNT_RT|"50%"|N|0.5||
|2|DISCOUNT_RT|"40%"|N|0.4||
|2|CURRENCY_SYMBOL|"EUR"|C||"EUR"|
|2|CURRENCY_SYMBOL|"HKD"|C||"HKD"|
<h4> SAS Macros </h4>
@li dc_assignlib.sas
@@ -96,13 +96,13 @@ run;
%dc_assignlib(READ,%scan(&tgtlibds,1,.))
proc contents noprint data=&tgtlibds
out=work.DYNAMIC_VALUES (keep=name rename=(name=raw_value) );
out=work.DYNAMIC_VALUES (keep=name rename=(name=display_value) );
run;
data work.DYNAMIC_VALUES;
set work.DYNAMIC_VALUES;
raw_value=upcase(display_value);
format raw_value;
raw_value=upcase(raw_value);
run;
@@ -18,10 +18,10 @@
The RAW_VALUE column may be charactor or numeric. If DISPLAY_INDEX is not
provided, it is added automatically.
|DISPLAY_INDEX:best.|RAW_VALUE|
|---|---|
|1|77.43|
|2|88.43|
|DISPLAY_INDEX:best.|DISPLAY_VALUE:$|RAW_VALUE|
|---|---|---|
|1|$77.43|77.43|
|2|$88.43|88.43|
<h5>DYNAMIC_EXTENDED_VALUES</h5>
This table is optional. If provided, it will map the DISPLAY_INDEX from the
@@ -31,17 +31,17 @@
Should be used sparingly! The use of large tables here can slow down the
browser.
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|
|---|---|---|---|---|
|1|DISCOUNT_RT|N|0.5||
|1|DISCOUNT_RT|N|0.4||
|1|DISCOUNT_RT|N|0.3||
|1|CURRENCY_SYMBOL|C||"GBP"|
|1|CURRENCY_SYMBOL|C||"RSD"|
|2|DISCOUNT_RT|N|0.5||
|2|DISCOUNT_RT|N|0.4||
|2|CURRENCY_SYMBOL|C||"EUR"|
|2|CURRENCY_SYMBOL|C||"HKD"|
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_VALUE:$|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|
|---|---|---|
|1|DISCOUNT_RT|"50%"|N|0.5||
|1|DISCOUNT_RT|"40%"|N|0.4||
|1|DISCOUNT_RT|"30%"|N|0.3||
|1|CURRENCY_SYMBOL|"GBP"|C||"GBP"|
|1|CURRENCY_SYMBOL|"RSD"|C||"RSD"|
|2|DISCOUNT_RT|"50%"|N|0.5||
|2|DISCOUNT_RT|"40%"|N|0.4||
|2|CURRENCY_SYMBOL|"EUR"|C||"EUR"|
|2|CURRENCY_SYMBOL|"HKD"|C||"HKD"|
<h4> SAS Macros </h4>
@li dc_getlibs.sas
@@ -56,7 +56,8 @@
proc sql;
create table work.DYNAMIC_VALUES as
select distinct upcase(libraryref) as raw_value
select distinct libraryname as display_value,
upcase(libraryref) as raw_value
from work.mm_getLibs
order by 1;
@@ -18,10 +18,10 @@
The RAW_VALUE column may be charactor or numeric. If DISPLAY_INDEX is not
provided, it is added automatically.
|DISPLAY_INDEX:best.|RAW_VALUE|
|---|---|
|1|77.43|
|2|88.43|
|DISPLAY_INDEX:best.|DISPLAY_VALUE:$|RAW_VALUE|
|---|---|---|
|1|$77.43|77.43|
|2|$88.43|88.43|
<h5>DYNAMIC_EXTENDED_VALUES</h5>
This table is optional. If provided, it will map the DISPLAY_INDEX from the
@@ -31,17 +31,17 @@
Should be used sparingly! The use of large tables here can slow down the
browser.
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_VALUE:$|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|
|---|---|---|
|1|DISCOUNT_RT|N|0.5||
|1|DISCOUNT_RT|N|0.4||
|1|DISCOUNT_RT|N|0.3||
|1|CURRENCY_SYMBOL|C||"GBP"|
|1|CURRENCY_SYMBOL|C||"RSD"|
|2|DISCOUNT_RT|N|0.5||
|2|DISCOUNT_RT|N|0.4||
|2|CURRENCY_SYMBOL|C||"EUR"|
|2|CURRENCY_SYMBOL|C||"HKD"|
|1|DISCOUNT_RT|"50%"|N|0.5||
|1|DISCOUNT_RT|"40%"|N|0.4||
|1|DISCOUNT_RT|"30%"|N|0.3||
|1|CURRENCY_SYMBOL|"GBP"|C||"GBP"|
|1|CURRENCY_SYMBOL|"RSD"|C||"RSD"|
|2|DISCOUNT_RT|"50%"|N|0.5||
|2|DISCOUNT_RT|"40%"|N|0.4||
|2|CURRENCY_SYMBOL|"EUR"|C||"EUR"|
|2|CURRENCY_SYMBOL|"HKD"|C||"HKD"|
**/
@@ -49,7 +49,8 @@
/* send back the raw and formatted values */
proc sql;
create table work.DYNAMIC_VALUES as
select distinct upcase(libref) as raw_value
select distinct libref as display_value,
upcase(libref) as raw_value
from &mpelib..mpe_tables
where &dc_dttmtfmt. < tx_to
order by 1;
@@ -12,16 +12,17 @@
<h4> Service Outputs </h4>
Output should be a single table called "work.dynamic_values" in the format
below. raw_value is unformatted haracter/numeric.
below. display_value should always be character, raw_value is unformatted
character/numeric.
<h5>DYNAMIC_VALUES</h5>
The RAW_VALUE column may be charactor or numeric. If DISPLAY_INDEX is not
provided, it is added automatically.
|DISPLAY_INDEX:best.|RAW_VALUE|
|---|---|
|1|77.43|
|2|88.43|
|DISPLAY_INDEX:best.|DISPLAY_VALUE:$|RAW_VALUE|
|---|---|---|
|1|$77.43|77.43|
|2|$88.43|88.43|
<h5>DYNAMIC_EXTENDED_VALUES</h5>
This table is optional. If provided, it will map the DISPLAY_INDEX from the
@@ -34,17 +35,17 @@
The FORCED_VALUE column can be used to force an extended value to be selected
by default when a particular value is chosen.
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|FORCED_VALUE|
|---|---|---|---|---|---|
|1|DISCOUNT_RT|N|0.5||.|
|1|DISCOUNT_RT|N|0.4||0|
|1|DISCOUNT_RT|N|0.3||1|
|1|CURRENCY_SYMBOL|C||"GBP"|.|
|1|CURRENCY_SYMBOL|C||"RSD"|.|
|2|DISCOUNT_RT|N|0.5||.|
|2|DISCOUNT_RT|N|0.4||1|
|2|CURRENCY_SYMBOL|C||"EUR"|.|
|2|CURRENCY_SYMBOL|C||"HKD"|1|
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_VALUE:$|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|FORCED_VALUE|
|---|---|---|---|
|1|DISCOUNT_RT|"50%"|N|0.5||.|
|1|DISCOUNT_RT|"40%"|N|0.4||0|
|1|DISCOUNT_RT|"30%"|N|0.3||1|
|1|CURRENCY_SYMBOL|"GBP"|C||"GBP"|.|
|1|CURRENCY_SYMBOL|"RSD"|C||"RSD"|.|
|2|DISCOUNT_RT|"50%"|N|0.5||.|
|2|DISCOUNT_RT|"40%"|N|0.4||1|
|2|CURRENCY_SYMBOL|"EUR"|C||"EUR"|.|
|2|CURRENCY_SYMBOL|"HKD"|C||"HKD"|1|
<h4> SAS Macros </h4>
@li dc_getlibs.sas
@@ -66,34 +67,38 @@ create table work.source as
where tx_to > &dc_dttmtfmt.
order by 1,2;
data work.DYNAMIC_VALUES (keep=display_index raw_value );
data work.DYNAMIC_VALUES (keep=display_index raw_value display_value);
set work.source end=last;
by libref;
if last.libref then do;
display_index+1;
raw_value=libref;
display_value=libref;
output;
end;
if last then do;
display_index+1;
raw_value='*ALL*';
display_value='*ALL*';
output;
end;
run;
data work.dynamic_extended_values(keep=display_index extra_col_name display_type
RAW_VALUE_CHAR raw_value_num forced_value);
display_value RAW_VALUE_CHAR raw_value_num forced_value);
set work.source end=last;
by libref dsn;
retain extra_col_name 'ALERT_DS';
retain display_type 'C';
retain raw_value_num .;
raw_value_char=dsn;
display_value=dsn;
forced_value=0;
if first.libref then display_index+1;
if last.libref then do;
display_value='*ALL*';
raw_value_char='*ALL*';
forced_value=1;
output;
@@ -13,16 +13,17 @@
<h4> Service Outputs </h4>
Output should be a single table called "work.dynamic_values" in the format
below.
below. display_value should always be character, raw_value is unformatted
character/numeric.
<h5>DYNAMIC_VALUES</h5>
The RAW_VALUE column may be charactor or numeric. If DISPLAY_INDEX is not
provided, it is added automatically.
|DISPLAY_INDEX:best.|RAW_VALUE|
|---|---|
|1|77.43|
|2|88.43|
|DISPLAY_INDEX:best.|DISPLAY_VALUE:$|RAW_VALUE|
|---|---|---|
|1|$77.43|77.43|
|2|$88.43|88.43|
<h5>DYNAMIC_EXTENDED_VALUES</h5>
This table is optional. If provided, it will map the DISPLAY_INDEX from the
@@ -35,18 +36,17 @@
The FORCED_VALUE column can be used to force an extended value to be selected
by default when a particular value is chosen.
|DISPLAY_INDEX|EXTRA_COL_NAME:$32.|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|
|---|---|---|---|---|
|1|DISCOUNT_RT|N|0.5||
|1|DISCOUNT_RT|N|0.4||
|1|DISCOUNT_RT|N|0.3||
|1|CURRENCY_SYMBOL|C||"GBP"|
|1|CURRENCY_SYMBOL|C||"RSD"|
|2|DISCOUNT_RT|N|0.5||
|2|DISCOUNT_RT|N|0.4||
|2|CURRENCY_SYMBOL|C||"EUR"|
|2|CURRENCY_SYMBOL|C||"HKD"|
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_VALUE:$|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|FORCED_VALUE|
|---|---|---|---|
|1|DISCOUNT_RT|"50%"|N|0.5||.|
|1|DISCOUNT_RT|"40%"|N|0.4||0|
|1|DISCOUNT_RT|"30%"|N|0.3||1|
|1|CURRENCY_SYMBOL|"GBP"|C||"GBP"|.|
|1|CURRENCY_SYMBOL|"RSD"|C||"RSD"|.|
|2|DISCOUNT_RT|"50%"|N|0.5||.|
|2|DISCOUNT_RT|"40%"|N|0.4||1|
|2|CURRENCY_SYMBOL|"EUR"|C||"EUR"|.|
|2|CURRENCY_SYMBOL|"HKD"|C||"HKD"|1|
<h4> SAS Macros </h4>
@li dc_getlibs.sas
@@ -93,24 +93,25 @@ create table work.source as
and memtype='DATA';
create table work.members as
select distinct upcase(memname) as raw_value
select distinct memname as display_value
from work.source;
data work.DYNAMIC_VALUES;
set work.members;
raw_value=display_value;
display_index=_n_;
run;
proc sql;
create table work.dynamic_extended_values as
select a.display_index
,a.raw_value
,b.name as display_value
,"C" as display_type
,b.name as RAW_VALUE_CHAR
,. as RAW_VALUE_NUM
from work.dynamic_values a
left join work.source b
on a.raw_value=b.memname
on a.display_value=b.memname
where b.type='num';
data work.dynamic_extended_values;
@@ -12,11 +12,12 @@
<h4> Service Outputs </h4>
Output should be a single table called "work.dynamic_values" in the format
below.
below. display_value should always be character, raw_value is unformatted
character/numeric.
|RAW_VALUE:??|
|---|
|44|
|DISPLAY_VALUE:$|RAW_VALUE:??|
|---|---|
|$44.00|44|
<h4> SAS Macros </h4>
@li dc_getlibs.sas
@@ -26,7 +27,8 @@
proc sql;
create table work.DYNAMIC_VALUES as
select distinct some_num as raw_value
select distinct cats(some_num) as display_value,
some_num as raw_value
from &libds
order by 1;
+16 -15
View File
@@ -17,10 +17,10 @@
The RAW_VALUE column may be charactor or numeric. If DISPLAY_INDEX is not
provided, it is added automatically.
|DISPLAY_INDEX:best.|RAW_VALUE|
|DISPLAY_INDEX:best.|DISPLAY_VALUE:$|RAW_VALUE|
|---|---|---|
|1|77.43|
|2|88.43|
|1|$77.43|77.43|
|2|$88.43|88.43|
<h5>DYNAMIC_EXTENDED_VALUES</h5>
This table is optional. If provided, it will map the DISPLAY_INDEX from the
@@ -30,17 +30,17 @@
Should be used sparingly! The use of large tables here can slow down the
browser.
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|
|---|---|---|---|---|
|1|DISCOUNT_RT|N|0.5||
|1|DISCOUNT_RT|N|0.4||
|1|DISCOUNT_RT|N|0.3||
|1|CURRENCY_SYMBOL|C||"GBP"|
|1|CURRENCY_SYMBOL|C||"RSD"|
|2|DISCOUNT_RT|N|0.5||
|2|DISCOUNT_RT|N|0.4||
|2|CURRENCY_SYMBOL|C||"EUR"|
|2|CURRENCY_SYMBOL|C||"HKD"|
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_VALUE:$|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|
|---|---|---|
|1|DISCOUNT_RT|"50%"|N|0.5||
|1|DISCOUNT_RT|"40%"|N|0.4||
|1|DISCOUNT_RT|"30%"|N|0.3||
|1|CURRENCY_SYMBOL|"GBP"|C||"GBP"|
|1|CURRENCY_SYMBOL|"RSD"|C||"RSD"|
|2|DISCOUNT_RT|"50%"|N|0.5||
|2|DISCOUNT_RT|"40%"|N|0.4||
|2|CURRENCY_SYMBOL|"EUR"|C||"EUR"|
|2|CURRENCY_SYMBOL|"HKD"|C||"HKD"|
@@ -55,6 +55,7 @@
proc sql;
create table work.DYNAMIC_VALUES as
select distinct groupname as raw_value
select distinct groupname as display_value,
groupname as raw_value
from work.groups
order by 1;
+16 -15
View File
@@ -18,10 +18,10 @@
The RAW_VALUE column may be charactor or numeric. If DISPLAY_INDEX is not
provided, it is added automatically.
|DISPLAY_INDEX:best.|RAW_VALUE|
|DISPLAY_INDEX:best.|DISPLAY_VALUE:$|RAW_VALUE|
|---|---|---|
|1|77.43|
|2|88.43|
|1|$77.43|77.43|
|2|$88.43|88.43|
<h5>DYNAMIC_EXTENDED_VALUES</h5>
This table is optional. If provided, it will map the DISPLAY_INDEX from the
@@ -31,17 +31,17 @@
Should be used sparingly! The use of large tables here can slow down the
browser.
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|
|---|---|---|---|---|
|1|DISCOUNT_RT|N|0.5||
|1|DISCOUNT_RT|N|0.4||
|1|DISCOUNT_RT|N|0.3||
|1|CURRENCY_SYMBOL|C||"GBP"|
|1|CURRENCY_SYMBOL|C||"RSD"|
|2|DISCOUNT_RT|N|0.5||
|2|DISCOUNT_RT|N|0.4||
|2|CURRENCY_SYMBOL|C||"EUR"|
|2|CURRENCY_SYMBOL|C||"HKD"|
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_VALUE:$|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|
|---|---|---|
|1|DISCOUNT_RT|"50%"|N|0.5||
|1|DISCOUNT_RT|"40%"|N|0.4||
|1|DISCOUNT_RT|"30%"|N|0.3||
|1|CURRENCY_SYMBOL|"GBP"|C||"GBP"|
|1|CURRENCY_SYMBOL|"RSD"|C||"RSD"|
|2|DISCOUNT_RT|"50%"|N|0.5||
|2|DISCOUNT_RT|"40%"|N|0.4||
|2|CURRENCY_SYMBOL|"EUR"|C||"EUR"|
|2|CURRENCY_SYMBOL|"HKD"|C||"HKD"|
<h4> SAS Macros </h4>
@li dc_assignlib.sas
@@ -89,7 +89,8 @@ run;
/* send back the raw and formatted values */
proc sql;
create table work.DYNAMIC_VALUES as
select distinct upcase(name) as raw_value
select distinct name as display_value,
upcase(name) as raw_value
from work.members
where MemType='DATA'
order by 1;
@@ -22,10 +22,10 @@
The RAW_VALUE column may be charactor or numeric. If DISPLAY_INDEX is not
provided, it is added automatically.
|DISPLAY_INDEX:best.|RAW_VALUE|
|---|---|
|1|77.43|
|2|88.43|
|DISPLAY_INDEX:best.|DISPLAY_VALUE:$|RAW_VALUE|
|---|---|---|
|1|$77.43|77.43|
|2|$88.43|88.43|
<h5>DYNAMIC_EXTENDED_VALUES</h5>
This table is optional. If provided, it will map the DISPLAY_INDEX from the
@@ -35,17 +35,17 @@
Should be used sparingly! The use of large tables here can slow down the
browser.
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|
|---|---|---|---|---|
|1|DISCOUNT_RT|N|0.5||
|1|DISCOUNT_RT|N|0.4||
|1|DISCOUNT_RT|N|0.3||
|1|CURRENCY_SYMBOL|C||"GBP"|
|1|CURRENCY_SYMBOL|C||"RSD"|
|2|DISCOUNT_RT|N|0.5||
|2|DISCOUNT_RT|N|0.4||
|2|CURRENCY_SYMBOL|C||"EUR"|
|2|CURRENCY_SYMBOL|C||"HKD"|
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_VALUE:$|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|
|---|---|---|---|---|---|
|1|DISCOUNT_RT|"50%"|N|0.5|` `|
|1|DISCOUNT_RT|"40%"|N|0.4|` `|
|1|DISCOUNT_RT|"30%"|N|0.3|` `|
|1|CURRENCY_SYMBOL|"GBP"|C|` `|"GBP"|
|1|CURRENCY_SYMBOL|"RSD"|C|` `|"RSD"|
|2|DISCOUNT_RT|"50%"|N|0.5|` `|
|2|DISCOUNT_RT|"40%"|N|0.4|` `|
|2|CURRENCY_SYMBOL|"EUR"|C|` `|"EUR"|
|2|CURRENCY_SYMBOL|"HKD"|C|` `|"HKD"|
**/
@@ -61,7 +61,8 @@ run;
proc sql;
create table work.DYNAMIC_VALUES as
select distinct upcase(dsn) as raw_value
select distinct dsn as display_value,
upcase(dsn) as raw_value
from &mpelib..mpe_tables
(where=(&dc_dttmtfmt. < tx_to))
where libref in (select &srccol from work.source_row)
@@ -17,10 +17,10 @@
The RAW_VALUE column may be charactor or numeric. If DISPLAY_INDEX is not
provided, it is added automatically.
|DISPLAY_INDEX:best.|RAW_VALUE|
|---|---|
|1|77.43|
|2|88.43|
|DISPLAY_INDEX:best.|DISPLAY_VALUE:$|RAW_VALUE|
|---|---|---|
|1|$77.43|77.43|
|2|$88.43|88.43|
<h5>DYNAMIC_EXTENDED_VALUES</h5>
This table is optional. If provided, it will map the DISPLAY_INDEX from the
@@ -30,17 +30,17 @@
Should be used sparingly! The use of large tables here can slow down the
browser.
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|
|---|---|---|---|---|
|1|DISCOUNT_RT|N|0.5||
|1|DISCOUNT_RT|N|0.4||
|1|DISCOUNT_RT|N|0.3||
|1|CURRENCY_SYMBOL|C||"GBP"|
|1|CURRENCY_SYMBOL|C||"RSD"|
|2|DISCOUNT_RT|N|0.5||
|2|DISCOUNT_RT|N|0.4||
|2|CURRENCY_SYMBOL|C||"EUR"|
|2|CURRENCY_SYMBOL|C||"HKD"|
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_VALUE:$|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|
|---|---|---|
|1|DISCOUNT_RT|"50%"|N|0.5||
|1|DISCOUNT_RT|"40%"|N|0.4||
|1|DISCOUNT_RT|"30%"|N|0.3||
|1|CURRENCY_SYMBOL|"GBP"|C||"GBP"|
|1|CURRENCY_SYMBOL|"RSD"|C||"RSD"|
|2|DISCOUNT_RT|"50%"|N|0.5||
|2|DISCOUNT_RT|"40%"|N|0.4||
|2|CURRENCY_SYMBOL|"EUR"|C||"EUR"|
|2|CURRENCY_SYMBOL|"HKD"|C||"HKD"|
@@ -55,6 +55,7 @@
proc sql;
create table work.DYNAMIC_VALUES as
select distinct groupuri as raw_value
select distinct groupname as display_value,
groupuri as raw_value
from work.groups
order by 1;