feat(editor): SAS VA data-driven content embed mode (?embed=va)
This commit is contained in:
+8
-1
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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[]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* SAS Visual Analytics data-driven content: earliest-possible message capture.
|
||||
*
|
||||
* Loaded as an external script from index.html (CSP-safe under a strict policy
|
||||
* — no inline script needed) and executed at HTML parse time, long before the
|
||||
* Angular app bootstraps. SAS VA posts its DDC data message once on iframe
|
||||
* load, which happens before Angular is up, so without this the very first
|
||||
* message (and any pre-set filter it carries) is lost.
|
||||
*
|
||||
* The latest captured message is stored on window.__vaLastMessage;
|
||||
* VaMessagingService reads it when it subscribes (closing the startup race) and
|
||||
* to recover the latest message across editor reloads.
|
||||
*
|
||||
* Self-guards on ?embed=va so it is completely inert in normal (non-VA) use.
|
||||
*/
|
||||
;(function () {
|
||||
'use strict'
|
||||
|
||||
try {
|
||||
var hash = window.location.hash || ''
|
||||
var query = hash.indexOf('?') >= 0 ? hash.split('?')[1] : ''
|
||||
if (new URLSearchParams(query).get('embed') !== 'va') return
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
|
||||
window.addEventListener(
|
||||
'message',
|
||||
function (event) {
|
||||
var data = event && event.data
|
||||
if (
|
||||
data &&
|
||||
typeof data === 'object' &&
|
||||
typeof data.resultName === 'string'
|
||||
) {
|
||||
// Store ONLY the latest message — readers just need the most recent, so
|
||||
// a single value keeps this bounded across a long session of filters.
|
||||
window.__vaLastMessage = {
|
||||
t: Date.now(),
|
||||
origin: event.origin,
|
||||
data: data
|
||||
}
|
||||
}
|
||||
},
|
||||
false
|
||||
)
|
||||
})()
|
||||
@@ -5,6 +5,15 @@
|
||||
<title>Data Controller</title>
|
||||
<!-- <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®" />
|
||||
|
||||
Reference in New Issue
Block a user