feat(editor): SAS VA data-driven content embed mode (?embed=va)

This commit is contained in:
s
2026-06-24 10:37:46 +02:00
parent 0ac3ff4511
commit ffa3ff9c10
12 changed files with 1170 additions and 17 deletions
+8 -1
View File
@@ -68,7 +68,14 @@
}
],
"styles": ["src/styles.scss"],
"scripts": ["node_modules/marked/marked.min.js"],
"scripts": [
"node_modules/marked/marked.min.js",
{
"input": "src/assets/va-early.js",
"bundleName": "va-early",
"inject": true
}
],
"webWorkerTsConfig": "tsconfig.worker.json",
"main": "src/main.ts"
},
+130
View File
@@ -0,0 +1,130 @@
const hostUrl = Cypress.env('hosturl')
const appLocation = Cypress.env('appLocation')
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
const serverType = Cypress.env('serverType')
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
// SAS Visual Analytics data-driven content embed mode (`?embed=va`):
// - chrome is hidden (same as embed=true),
// - the editor opens in edit mode immediately (not read-only),
// - the only action button is a single Submit below the grid (stubbed in v1),
// - the top button bar and Add Record are hidden.
//
// The detailed PK+label data-merge logic is covered by the VaMessagingService
// unit spec; here we only assert the deterministic UI layout and that pushing a
// VA postMessage does not break the page.
context('embed=va (VA data-driven content) tests: ', function () {
this.beforeAll(() => {
cy.visit(`${hostUrl}/SASLogon/logout`)
cy.loginAndUpdateValidKey()
})
this.beforeEach(() => {
cy.visit(hostUrl + appLocation)
visitPage('home')
})
it('1 | opens the editor in VA mode: chrome hidden, editable, single bottom Submit', () => {
openEditorInVaMode('mpe_x_test', () => {
// Chrome hidden (same as embed=true)
cy.get('header.app-header').should('not.exist')
// Grid present and in edit mode (read-only Filter/Edit/Upload bar absent)
cy.get('#hotTable', { timeout: longerCommandTimeout }).should('exist')
cy.get('.btnCtrl button').contains('Edit').should('not.exist')
cy.get('.btnCtrl button').contains('Filter').should('not.exist')
// Top edit-mode buttons hidden in VA mode
cy.get('.btnCtrl button.btn-outline-danger').should('not.exist') // Cancel
cy.get('.btnCtrl').contains('Add Row').should('not.exist')
// Bottom Add Record hidden
cy.contains('button', 'Add Record').should('not.exist')
// Exactly one Submit button, below the grid, disabled in v1
cy.get('button').filter(':contains("Submit")').should('have.length', 1)
cy.get('button').contains('Submit').should('be.disabled')
})
})
it('2 | accepts a VA postMessage and stays in VA mode', () => {
// A VA message drives column visibility + filtering; an empty/unmatched
// message reloads the editor (unfiltered) while preserving ?embed=va.
openEditorInVaMode('mpe_x_test', () => {
cy.window().then((win) => {
win.postMessage(
{
version: '1',
resultName: 'dd1',
rowCount: 0,
availableRowCount: 0,
data: [],
columns: [],
parameters: []
},
'*'
)
})
// Editor still loads in VA mode after the message (chrome hidden, grid up)
cy.get('#hotTable', { timeout: longerCommandTimeout }).should('exist')
cy.get('header.app-header').should('not.exist')
cy.get('button').contains('Submit').should('exist')
})
})
})
// Opens a table from the tree (which routes to #/editor/LIB.TABLE), then
// re-visits the same route with ?embed=va so the app parses VA embed mode.
const openEditorInVaMode = (tablename: string, callback?: any) => {
openTableFromTree(libraryToOpenIncludes, tablename)
cy.get('#hotTable', { timeout: longerCommandTimeout })
.should('exist')
.then(() => {
cy.url().then((url) => {
const separator = url.includes('?') ? '&' : '?'
cy.visit(`${url}${separator}embed=va`)
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
'not.exist'
)
if (callback) callback()
})
})
}
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
cy.get('.app-loading', { timeout: longerCommandTimeout })
.should('not.exist')
.then(() => {
cy.get('.nav-tree clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
}).then((treeNodes: any) => {
let viyaLib
for (let node of treeNodes) {
if (node.innerText.toLowerCase().includes(libNameIncludes)) {
viyaLib = node
break
}
}
cy.get(viyaLib).within(() => {
cy.get('.clr-tree-node-content-container > button').click()
cy.get('.clr-treenode-link').then((innerNodes: any) => {
for (let innerNode of innerNodes) {
if (innerNode.innerText.toLowerCase().includes(tablename)) {
innerNode.click()
break
}
}
})
})
})
})
}
const visitPage = (url: string) => {
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
}
+1 -1
View File
@@ -55,7 +55,7 @@ export interface HandsontableStaticConfig {
* Cached viyaApi collections, search and selected endpoint
*/
export const globals: {
embed: boolean
embed: boolean | 'va'
rootParam: string
dcLib: string
xlmaps: XLMapListItem[]
+5 -7
View File
@@ -11,6 +11,7 @@ import { Location } from '@angular/common'
import '@clr/icons'
import '@clr/icons/shapes/all-shapes'
import { globals } from './_globals'
import { parseEmbedParam } from './shared/utils/parse-embed-param'
import moment from 'moment'
import { EventService } from './services/event.service'
import { AppService } from './services/app.service'
@@ -145,13 +146,10 @@ export class AppComponent {
})
const hashQuery = window.location.hash.split('?')[1]
if (hashQuery) {
const embedParam = new URLSearchParams(hashQuery).get('embed')
if (embedParam !== null) {
const isEmbed = embedParam !== 'false'
globals.embed = isEmbed
this.embed = isEmbed
}
if (hashQuery && new URLSearchParams(hashQuery).get('embed') !== null) {
const embed = parseEmbedParam(hashQuery)
globals.embed = embed
this.embed = embed
}
this.subscribeToShowAbortModal()
+23 -2
View File
@@ -285,7 +285,9 @@
</button>
</ng-container>
<ng-container *ngIf="!hotTable.readOnly && !uploadPreview">
<ng-container
*ngIf="!hotTable.readOnly && !uploadPreview && !isVaEmbed"
>
<button
type="button"
class="btn btn-sm btn-icon btn-outline-danger"
@@ -435,7 +437,9 @@
<div>
<clr-tooltip
*ngIf="tableTrue && !restrictions.removeAddRecordButton"
*ngIf="
tableTrue && !restrictions.removeAddRecordButton && !isVaEmbed
"
>
<button
clrTooltipTrigger
@@ -466,6 +470,23 @@
>
</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">
<button
type="button"
class="btn btn-sm btn-primary mt-5-i"
[disabled]="submitLoading"
(click)="checkSave()"
title="Submit changes for approval"
>
<clr-icon aria-hidden="true" shape="check" size="20"></clr-icon>
Submit
</button>
</ng-container>
<p
*ngIf="
licenceState.value.editor_rows_allowed !== Infinity &&
+579 -6
View File
@@ -34,7 +34,7 @@ import {
Version
} from '../models/sas/editors-getdata.model'
import { DataFormat } from '../models/sas/common/DateFormat'
import { Approver, ExcelRule } from '../models/TableData'
import { Approver, ExcelRule, QueryClause } from '../models/TableData'
import { QueryComponent } from '../query/query.component'
import { EventService } from '../services/event.service'
import { HelperService } from '../services/helper.service'
@@ -61,6 +61,7 @@ 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 { UploadFileResponse } from '../models/UploadFile'
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
import { ParseResult } from '../models/ParseResult.interface'
@@ -282,6 +283,37 @@ 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'
}
/** 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, avoiding intermediate reloads stranding a middle value.
*/
private static readonly VA_DEBOUNCE_MS = 500
/** SAS month abbreviations for parsing VA's date/datetime strings. */
private static readonly SAS_MONTHS = [
'JAN',
'FEB',
'MAR',
'APR',
'MAY',
'JUN',
'JUL',
'AUG',
'SEP',
'OCT',
'NOV',
'DEC'
]
public tableTrue: boolean | undefined
public saveLoading = false
public approvers: string[] = []
@@ -440,7 +472,8 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
private route: ActivatedRoute,
private sasService: SasService,
private cdf: ChangeDetectorRef,
private spreadsheetService: SpreadsheetService
private spreadsheetService: SpreadsheetService,
private vaMessaging: VaMessagingService
) {
this.parseRestrictions()
this.setRestrictions()
@@ -934,16 +967,32 @@ 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 columnSortConfig = columnSorting.getSortConfig()
const sortConfigs = Array.isArray(columnSortConfig)
? columnSortConfig
: [columnSortConfig]
const sortConfigs = this.getCurrentSortConfigs()
setTimeout(() => {
if (!previewEdit) {
@@ -2596,6 +2645,12 @@ 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()
@@ -2640,6 +2695,12 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
}
}
// VA initial load: if VA's first message already carries a filter, apply it
// BEFORE the first getdata so the initial fetch is filtered (no full-table
// load + reload). Navigates away on success; this instance aborts its load.
// Falls back to the normal unfiltered load on timeout/no-filter/failure.
if (await this.maybeDeferInitialVaLoad()) return
if (this.libds) {
this.getdataError = false
@@ -2655,6 +2716,119 @@ 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.buildInitialVaClauses(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.vaFilterSignature(clauses)
await this.reloadEditorRoute('/editor/' + table + '/' + id)
return true
} catch {
return false
}
}
/**
* Builds filter clauses from VA parameters WITHOUT DC column metadata (used
* pre-getdata on initial load). Uses the VA `label` as the DC column name and
* infers numeric from the VA `dataType`. Char values are escaped via the same
* formatRawFilterValue used elsewhere. Empty-valued params are skipped.
*/
private buildInitialVaClauses(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
// No DC metadata here (pre-getdata), so infer the SAS kind from the value
// shape (VA's temporal strings are distinctive) and reuse the same
// formatter as the metadata path — so the deferred clause matches what the
// post-load metadata build produces (no reconcile) and temporal columns
// get the numeric internal value DC's backend requires.
const formatted = nonEmpty
.map((v) =>
this.formatVaFilterValueByKind(
v,
this.inferVaKindFromValue(String(v).trim(), param.dataType)
)
)
.filter((v): v is string => v !== null)
if (formatted.length === 0) continue
clauses.push({
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(',')})`
})
}
return clauses
}
/**
* Infers a column's SAS kind from a VA value string when DC metadata isn't
* available yet (deferred initial load). 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`. The metadata path
* (`vaColumnKind`) is authoritative and reconciles if this guesses wrong.
*/
private inferVaKindFromValue(
value: string,
dataType?: string
): 'char' | 'numeric' | 'time' | 'date' | 'datetime' {
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'
}
ngAfterViewInit() {
// Fix ARIA accessibility issues after table initialization
setTimeout(() => {
@@ -2695,6 +2869,18 @@ 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
}
}
/**
@@ -3406,5 +3592,392 @@ 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.
if (this.vaMessaging.latestMessage()) this.scheduleVaApply()
}
}
/**
* 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.scheduleVaApply())
}
/**
* Debounces incoming VA messages: each message (re)starts the timer, and when
* it settles the latest buffered message is applied. Coalesces rapid changes
* (e.g. clicking several control options quickly) into one filter reload, and
* harmlessly no-ops while the grid isn't ready yet (applyVaMessage guards on
* libds/cellValidation; the buffered message is replayed at load-complete).
*/
private scheduleVaApply() {
if (this.vaDebounceTimer) clearTimeout(this.vaDebounceTimer)
this.vaDebounceTimer = setTimeout(() => {
this.vaDebounceTimer = undefined
// Use the always-on buffer's latest, not the (reload-gappy) lastMessage,
// so the most recent VA selection wins even across a filter reload.
const msg = this.vaMessaging.latestMessage()
if (msg) this.applyVaMessage(msg)
}, EditorComponent.VA_DEBOUNCE_MS)
}
/**
* 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 (e.g. temporal/numeric) still
* changes the signature and reconciles correctly.
*/
private vaFilterSignature(clauses: QueryClause[]): string {
return JSON.stringify(
clauses.map((c) => ({
...c,
VARIABLE_NM: (c.VARIABLE_NM ?? '').toString().toUpperCase()
}))
)
}
/**
* Applies one VA message. The grid data itself always comes from DC's getdata
* (SAS post-processes it into HOT format and wires the validation hooks); VA
* only drives (a) which columns are shown/hidden and (b) the active filter.
* VA `parameters` (control/prompt values) are translated into a DC filter,
* saved through DC's own validatefilter flow, and applied by reloading at
* /editor/<table>/<FILTER_RK> so the filter key is reflected in the URL.
*/
private applyVaMessage(msg: VaMessage) {
if (!this.libds || !this.cellValidation) return
// Grid not ready yet (cellValidation only has the delete column, or none):
// buildVaLabelToVarMap 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-applies once metadata is in.
if (this.cellValidation.length <= 1) return
const labelToVar = this.buildVaLabelToVarMap()
// 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)
}
this.updateVaFilterText(msg)
// Translate VA parameters into a DC filter and apply it (if it changed).
const clauses = this.buildClausesFromVaParameters(msg, labelToVar)
const signature = this.vaFilterSignature(clauses)
if (signature !== this.vaMessaging.filterSignature) {
this.vaMessaging.filterSignature = signature
void this.applyVaFilter(clauses)
} else if (visibilityChanged && this.hotInstance) {
this.hotInstance.render()
}
}
/** label/name (lower-cased) -> DC variable (prop) name, skipping the delete col. */
private buildVaLabelToVarMap(): Map<string, string> {
const map = new Map<string, string>()
for (let i = 1; i < this.cellValidation.length; i++) {
const varName = this.cellValidation[i]?.data
if (!varName) continue
map.set(
(this.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 (...)`. Char values are quoted, numeric left raw.
*/
private buildClausesFromVaParameters(
msg: VaMessage,
labelToVar: Map<string, string>
): 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.vaColumnKind(varName)
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); time -> SAS internal seconds; char -> escaped.
// Invalid values are dropped.
const formatted = rawValues
.filter((v) => v !== undefined && v !== null && v !== '')
.map((v) => this.formatVaFilterValueByKind(v, kind))
.filter((v): v is string => v !== null)
if (formatted.length === 0) continue
clauses.push({
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(',')})`
})
}
return clauses
}
/**
* Formats a filter value for a SAS WHERE clause, or returns null when it is
* invalid. Numeric columns must receive a finite number — anything else is
* rejected so untrusted input cannot be injected as raw SQL; char values are
* single-quote escaped.
*/
private formatRawFilterValue(value: any, isNum: boolean): string | null {
if (isNum) {
const num = Number(value)
return Number.isFinite(num) ? `${num}` : null
}
return `'${String(value).replace(/'/g, "''")}'`
}
/**
* SAS data-type kind of a column, from the loaded column specs (this.cols).
* DDTYPE carries TIME/DATE/DATETIME; more reliable than isNumericVar (which
* misclassifies time columns). Checks DATETIME before DATE (substring).
*/
private vaColumnKind(
varName: string
): 'char' | 'numeric' | 'time' | 'date' | 'datetime' {
const col = (this.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'
}
/**
* Formats one VA filter value into a DC RAW_VALUE for the column's SAS 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 DC's own helperService.convertJsDateToSasDate so the
* value is byte-identical to what DC's pickers produce.
*/
private formatVaFilterValueByKind(value: any, kind: string): 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.vaTimeToSeconds(s)
return secs === null ? null : `${secs}`
}
case 'date': {
const d = this.vaSasDateToJsDate(s)
return d
? `${this.helperService.convertJsDateToSasDate(d, 'days')}`
: null
}
case 'datetime': {
const d = this.vaSasDatetimeToJsDate(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 vaTimeToSeconds(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 vaSasDateToJsDate(value: string): Date | null {
const m = /^(\d{1,2})([A-Za-z]{3})(\d{4})$/.exec(value)
if (!m) return null
const monIdx = EditorComponent.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 vaSasDatetimeToJsDate(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 = EditorComponent.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)
)
}
private isNumericVar(varName: string): boolean {
const formatType = this.$dataFormats?.vars?.[varName]?.type
if (formatType) return formatType === 'num'
const rule = this.cellValidation.find((c: any) => c?.data === varName)
return ['numeric', 'intl-date', 'intl-time', 'intl-datetime'].includes(
rule?.type as string
)
}
/**
* 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[]) {
if (this.vaApplyingFilter) return
this.vaApplyingFilter = true
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') 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) return
await this.reloadEditorRoute('/editor/' + table + '/' + id)
} catch {
// Keep the current view on failure; the next message can retry.
} finally {
this.vaApplyingFilter = false
}
}
/** 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)
}
}
@@ -0,0 +1,102 @@
import { VaMessagingService, VaMessage } from './va-messaging.service'
// Sample message taken from the official SAS DDC docs (brush column included).
const SAMPLE = {
version: '1',
resultName: 'dd40',
rowCount: 3,
availableRowCount: 3,
data: [
['Finch', 95000.0, 0.4285],
['Jones', 26000.0, 0.0],
['Smith', 108000.0, 0.5]
],
columns: [
{ name: 'bi184', label: 'dealer', type: 'string' },
{
name: 'bi258',
label: 'sales',
type: 'number',
usage: 'quantitative',
aggregation: 'sum'
},
{ name: 'ri1', type: 'number', usage: 'brush' }
]
}
describe('VaMessagingService', () => {
let service: VaMessagingService
beforeEach(() => {
service = new VaMessagingService()
})
describe('parseMessage', () => {
it('parses a valid DDC message and defaults parameters to []', () => {
const msg = service.parseMessage({ data: SAMPLE } as MessageEvent)
expect(msg).not.toBeNull()
expect(msg!.resultName).toBe('dd40')
expect(msg!.rowCount).toBe(3)
expect(msg!.parameters).toEqual([])
})
it('returns null for unrelated / malformed messages', () => {
expect(service.parseMessage({ data: null } as MessageEvent)).toBeNull()
expect(
service.parseMessage({ data: 'hello' } as unknown as MessageEvent)
).toBeNull()
expect(
service.parseMessage({ data: { columns: [] } } as MessageEvent)
).toBeNull()
})
})
describe('dataColumns', () => {
it('excludes brush columns and keeps original indices', () => {
const msg = service.parseMessage({ data: SAMPLE } as MessageEvent)!
const cols = service.dataColumns(msg)
expect(cols.map((c) => c.column.name)).toEqual(['bi184', 'bi258'])
expect(cols.map((c) => c.index)).toEqual([0, 1])
})
})
describe('onData', () => {
const sameOrigin = window.location.origin
it('invokes the callback for valid messages and unsubscribes cleanly', () => {
const received: VaMessage[] = []
const off = service.onData((m) => received.push(m))
window.dispatchEvent(
new MessageEvent('message', { data: SAMPLE, origin: sameOrigin })
)
expect(received.length).toBe(1)
expect(received[0].resultName).toBe('dd40')
// unrelated traffic is ignored
window.dispatchEvent(
new MessageEvent('message', { data: { foo: 1 }, origin: sameOrigin })
)
expect(received.length).toBe(1)
off()
window.dispatchEvent(
new MessageEvent('message', { data: SAMPLE, origin: sameOrigin })
)
expect(received.length).toBe(1)
})
it('rejects messages from an untrusted origin', () => {
const received: VaMessage[] = []
service.onData((m) => received.push(m))
window.dispatchEvent(
new MessageEvent('message', {
data: SAMPLE,
origin: 'https://evil.example.com'
})
)
expect(received.length).toBe(0)
})
})
})
@@ -0,0 +1,218 @@
import { Injectable } from '@angular/core'
/**
* SAS Visual Analytics data-driven content (DDC) postMessage interface.
*
* Contract (from the official SAS docs and the sample repo
* https://github.com/sassoftware/sas-visualanalytics-thirdpartyvisualizations):
* VA shares data with the data-driven content object via window.postMessage().
* The content registers a `message` listener; `event.data` is a JSON object.
* To act as the source of an action (selection / brushing) or to show an
* instructional message, the content posts a message back to `window.parent`,
* echoing the `resultName`.
*/
export interface VaColumn {
/** Internal VA column id, e.g. "bi184". */
name: string
/** Human label, e.g. "dealer". May be absent (e.g. on brush columns). */
label?: string
type: string
/** "categorical" | "quantitative" | "brush" | ... */
usage?: string
aggregation?: string
format?: any
}
export interface VaParameter {
/** VA internal id, e.g. "pr273". NOT the DC column. */
name: string
/** Human label — equals the DC column name in our setup, e.g. "some_dropdown". */
label?: string
/** "number" | "string" — VA's value type (used to decide numeric vs char). */
dataType?: string
type?: string
format?: any
value: any
}
export interface VaMessage {
version?: string
/** Required to echo back on any message to VA. */
resultName: string
rowCount: number
availableRowCount?: number
/** Row-major 2-D array; cell order matches `columns`. Measures are unformatted. */
data: any[][]
columns: VaColumn[]
/** Only the parameters used by the query are returned. */
parameters: VaParameter[]
}
@Injectable({ providedIn: 'root' })
export class VaMessagingService {
/** Result name from the most recent message, needed for the back-channel. */
private resultName: string | null = null
/**
* Origin of the last trusted inbound message — used as the explicit
* targetOrigin for the back-channel so payloads are never broadcast to an
* unintended parent.
*/
private parentOrigin: string | null = null
/**
* Validates that a message comes from a trusted source: our hosting frame
* (the normal SAS VA data-driven content case) or the same origin (local dev /
* tests). Untrusted messages — e.g. from an unrelated window or a malicious
* iframe — are rejected.
*/
private isTrustedSource(event: MessageEvent): boolean {
if (window.parent !== window && event.source === window.parent) return true
return !!event.origin && event.origin === window.location.origin
}
/**
* Cross-reload state. Applying a VA filter navigates to
* /editor/<table>/<FILTER_RK>, which recreates the editor component, so the
* desired filter + column visibility are held on this root singleton and
* re-applied after the reload.
*/
filterSignature: string | null = null
/** DC variable names that should be visible (the rest are hidden). */
visibleColumns: string[] | null = null
/** True once the pre-bootstrap early buffer has been drained (drain-once). */
private earlyDrained = false
/**
* Registers a window `message` listener and invokes `callback` for every
* valid DDC message. The latest message itself is read via `latestMessage()`
* from the always-on `window.__vaLastMessage` buffer, not stored here.
* Returns an unsubscribe function that removes the listener.
*/
onData(callback: (msg: VaMessage) => void): () => void {
const handler = (event: MessageEvent) => {
if (!this.isTrustedSource(event)) return
const parsed = this.parseMessage(event)
if (parsed) {
this.resultName = parsed.resultName
if (event.origin) this.parentOrigin = event.origin
callback(parsed)
}
}
window.addEventListener('message', handler, false)
// Replay any DDC message captured by the early listener before
// the app (and this listener) existed. VA pushes its message once on iframe
// load — which happens before Angular bootstraps — so without this the very
// first message is lost entirely and the editor loads unfiltered.
this.replayEarlyMessages(callback)
return () => window.removeEventListener('message', handler, false)
}
/**
* The true latest DDC message, from the always-on `window.__vaLastMessage`
* buffer (set in va-early.js). That listener is registered at page parse and
* never removed, so it has NO gaps — including the window during an editor
* reload when the app's own listener is briefly absent. So a message that
* arrives mid-reload (e.g. a rapid filter change) is still here.
*/
latestMessage(): VaMessage | null {
const captured = (window as unknown as { __vaLastMessage?: any })
.__vaLastMessage
return captured ? this.parseData(captured.data) : null
}
/**
* Replays the latest VA message captured by the early `window.__vaLastMessage`
* listener (set up in va-early.js), closing the pre-bootstrap race. Only runs
* when actually framed (DDC context).
*/
private replayEarlyMessages(callback: (msg: VaMessage) => void): void {
if (window.parent === window) return
// Drain only once per page lifetime. This service is a root singleton, so
// the flag persists across editor reloads — without it every reload would
// re-replay the captured message and fight the live filter (loop).
if (this.earlyDrained) return
this.earlyDrained = true
const captured = (window as unknown as { __vaLastMessage?: any })
.__vaLastMessage
const parsed = this.parseData(captured && captured.data)
if (!parsed) return
this.resultName = parsed.resultName
if (captured.origin) this.parentOrigin = captured.origin
callback(parsed)
}
/**
* Parses a raw window MessageEvent into a VaMessage, or null when it is not a
* recognisable DDC message (e.g. unrelated postMessage traffic).
*/
parseMessage(event: MessageEvent): VaMessage | null {
return this.parseData(event && event.data)
}
/**
* Parses a raw message payload (from a MessageEvent or the early buffer) into
* a VaMessage, or null when it is not a recognisable DDC message.
*/
private parseData(data: any): VaMessage | null {
if (!data || typeof data !== 'object') return null
if (typeof data.resultName !== 'string') return null
if (!Array.isArray(data.columns) || !Array.isArray(data.data)) return null
return {
version: data.version,
resultName: data.resultName,
rowCount: data.rowCount,
availableRowCount: data.availableRowCount,
data: data.data,
columns: data.columns,
parameters: Array.isArray(data.parameters) ? data.parameters : []
}
}
/**
* Columns that carry grid data — excludes `usage: "brush"` columns, which
* encode linked-selection state rather than data.
*/
dataColumns(msg: VaMessage): { column: VaColumn; index: number }[] {
return msg.columns
.map((column, index) => ({ column, index }))
.filter(({ column }) => column.usage !== 'brush')
}
/**
* Back-channel (DDC → VA) — selection / source-of-action. Designed per the
* SAS contract; not wired into the UI in v1.
*/
sendSelection(rows: number[]): void {
if (this.resultName === null) return
this.sendMessage({
resultName: this.resultName,
selections: rows.map((row) => ({ row }))
})
}
/**
* Back-channel (DDC → VA) — custom instructional message, shown in the report
* designer when the assigned data does not meet the visual's requirements.
*/
sendInstructionalMessage(message: string): void {
if (this.resultName === null) return
this.sendMessage({ resultName: this.resultName, message })
}
private sendMessage(message: any): void {
// Prefer the validated origin captured from the inbound VA message; only
// fall back to the referrer/own location when we have not yet received one.
const target =
this.parentOrigin ||
(window.location !== window.parent.location
? document.referrer
: document.location.href)
if (!target) return
window.parent.postMessage(message, target)
}
}
@@ -0,0 +1,26 @@
import { parseEmbedParam } from './parse-embed-param'
describe('parseEmbedParam', () => {
it('returns false when there is no hash query', () => {
expect(parseEmbedParam('')).toBe(false)
})
it('returns false when the embed param is absent', () => {
expect(parseEmbedParam('foo=bar')).toBe(false)
})
it("returns 'va' for embed=va", () => {
expect(parseEmbedParam('embed=va')).toBe('va')
expect(parseEmbedParam('foo=bar&embed=va')).toBe('va')
})
it('returns false for embed=false', () => {
expect(parseEmbedParam('embed=false')).toBe(false)
})
it('returns true for embed=true and other truthy values', () => {
expect(parseEmbedParam('embed=true')).toBe(true)
expect(parseEmbedParam('embed=1')).toBe(true)
expect(parseEmbedParam('embed=')).toBe(true)
})
})
@@ -0,0 +1,22 @@
/**
* Parses the `embed` value out of a hash query string (the part after `?` in
* `window.location.hash`).
*
* Returns:
* - `'va'` when `embed=va` (SAS Visual Analytics data-driven content mode)
* - `false` when `embed=false`, or when the param is absent
* - `true` for any other non-empty value (legacy boolean embed)
*
* `'va'` is intentionally truthy so existing `!embed` chrome checks keep hiding
* the header/back/subnav exactly as they do for `embed=true`.
*/
export function parseEmbedParam(hashQuery: string): boolean | 'va' {
if (!hashQuery) return false
const embedParam = new URLSearchParams(hashQuery).get('embed')
if (embedParam === null) return false
if (embedParam === 'va') return 'va'
return embedParam !== 'false'
}
+47
View File
@@ -0,0 +1,47 @@
/*
* SAS Visual Analytics data-driven content: earliest-possible message capture.
*
* Loaded as an external script from index.html (CSP-safe under a strict policy
* — no inline script needed) and executed at HTML parse time, long before the
* Angular app bootstraps. SAS VA posts its DDC data message once on iframe
* load, which happens before Angular is up, so without this the very first
* message (and any pre-set filter it carries) is lost.
*
* The latest captured message is stored on window.__vaLastMessage;
* VaMessagingService reads it when it subscribes (closing the startup race) and
* to recover the latest message across editor reloads.
*
* Self-guards on ?embed=va so it is completely inert in normal (non-VA) use.
*/
;(function () {
'use strict'
try {
var hash = window.location.hash || ''
var query = hash.indexOf('?') >= 0 ? hash.split('?')[1] : ''
if (new URLSearchParams(query).get('embed') !== 'va') return
} catch (e) {
return
}
window.addEventListener(
'message',
function (event) {
var data = event && event.data
if (
data &&
typeof data === 'object' &&
typeof data.resultName === 'string'
) {
// Store ONLY the latest message — readers just need the most recent, so
// a single value keeps this bounded across a long session of filters.
window.__vaLastMessage = {
t: Date.now(),
origin: event.origin,
data: data
}
}
},
false
)
})()
+9
View File
@@ -5,6 +5,15 @@
<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®" />