feat(embed): map VA parameters to DC columns from the URL
Build / Build-and-ng-test (pull_request) Successful in 5m33s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m20s
Build / Build-and-test-development (pull_request) Successful in 32m46s

Two DC instances over the same table in one VA report could not filter the
same column from different parameters: parameter -> column matching is by
name, and two VA parameters cannot share a label.

The `va_parmap` URL parameter overrides that match per instance, one entry
per mapping (`va_parmap=<VA parameter>-<DC column>`, repeated for more):

  ...?embed=va&va_parmap=REGION_TAB1-REGION

The mapping is additive (unmapped parameters keep the name match), the
column is validated as a plain SAS name at parse time, and an entry only
applies where the named column exists in the table being opened - a miss
skips that parameter rather than falling back to the same-name match the
mapping exists to override. Entries split at the last hyphen, so a label
may itself contain hyphens, and the key matches the parameter label first,
then its internal name (pr###).

Parsed once at app init onto globals (like embed / vaApplyMode) so the
editor reloads a filter apply performs keep the mapping, and both filter
paths (the metadata build and the deferred initial load) honour it.

Also repairs embed-va.cy.ts: a cy.visit differing from the current URL
only in the hash is a same-document navigation, so ?embed=va never took
effect and both existing cases failed; their layout assertions also
described an earlier design (VA now opens read-only with an Edit button
and hides Filter). Cases 3 and 4 drive two mappings of the same column
through a real postMessage and assert the applied filter and row count.
This commit is contained in:
dc committed 2026-10-28 08:41:15 +00:00
1 parent 424b111737
commit 15a1f34d52
8 files changed
+431 -27

No files matched your search

+100 -17
View File
@@ -6,13 +6,14 @@ 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 editor opens READ-ONLY (the user clicks Edit, as in normal use),
// - the Filter button is hidden - VA filters replace the filter builder,
// - the Add Row / Add Record buttons 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.
// unit spec; here we assert the deterministic UI layout, that pushing a VA
// postMessage does not break the page, and (case 3) that a `va_parmap` URL
// mapping filters the column it names end to end.
context('embed=va (VA data-driven content) tests: ', function () {
this.beforeAll(() => {
cy.visit(`${hostUrl}/SASLogon/logout`)
@@ -21,29 +22,33 @@ context('embed=va (VA data-driven content) tests: ', function () {
this.beforeEach(() => {
cy.visit(hostUrl + appLocation)
// A cy.visit that differs from the current URL only in the hash is a
// same-document navigation, so the app is not re-bootstrapped. Reload so
// each test starts from a fresh document (the previous test may have left
// ?embed=va in the URL, which the app reads once at init).
cy.reload()
visitPage('home')
})
it('1 | opens the editor in VA mode: chrome hidden, editable, single bottom Submit', () => {
it('1 | opens the editor in VA mode: chrome hidden, read-only, no Filter button', () => {
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)
// Grid present, read-only: the user enters edit mode with Edit, as usual
cy.get('#hotTable', { timeout: longerCommandTimeout }).should('exist')
cy.get('.btnCtrl button').contains('Edit').should('not.exist')
cy.get('.btnCtrl button').contains('Edit').should('exist')
// The filter builder is replaced by VA filters
cy.get('.btnCtrl button').contains('Filter').should('not.exist')
// Top edit-mode buttons hidden in VA mode
// Not in edit mode, so no edit-mode controls
cy.get('.btnCtrl button.btn-outline-danger').should('not.exist') // Cancel
cy.get('.btnCtrl').contains('Add Row').should('not.exist')
cy.get('.btnCtrl').contains('Submit').should('not.exist')
// Bottom Add Record hidden
// Bottom Add Record hidden in VA mode
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')
})
})
@@ -69,14 +74,86 @@ context('embed=va (VA data-driven content) tests: ', function () {
// 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')
cy.get('.btnCtrl button').contains('Edit').should('exist')
})
})
it('3 | va_parmap filters a column from a parameter that is not its name (tab 1)', () => {
// Two DC instances over one table (one per report tab), each filtering the
// same DC column from its own report parameter. Without the URL mapping the
// parameter label is not a column name, so nothing would filter at all.
openEditorInVaMode(
'mpe_x_test',
() => {
pushVaMessage({
name: 'pr1',
label: 'DROPDOWN_TAB1',
dataType: 'string',
value: 'Option 2'
})
// The applied filter names the MAPPED column, and the grid shrinks to
// the rows matching that value (Option 2 occurs twice in this table).
cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should(
'contain.text',
'SOME_DROPDOWN'
)
cy.get('.infoBar-text').should('contain.text', "'Option 2'")
cy.get('h4').should('contain.text', '(2 rows')
},
'va_parmap=DROPDOWN_TAB1-SOME_DROPDOWN'
)
})
it('4 | the other tab maps its own parameter to the same column', () => {
// Same table, same column, a different parameter label and value (Option 1
// occurs four times) - the per-instance URL mapping is what keeps the two
// tabs independent.
openEditorInVaMode(
'mpe_x_test',
() => {
pushVaMessage({
name: 'pr1',
label: 'DROPDOWN_TAB2',
dataType: 'string',
value: 'Option 1'
})
cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should(
'contain.text',
'SOME_DROPDOWN'
)
cy.get('.infoBar-text').should('contain.text', "'Option 1'")
cy.get('h4').should('contain.text', '(4 rows')
},
'va_parmap=DROPDOWN_TAB2-SOME_DROPDOWN'
)
})
})
/** Pushes one VA data-driven content message carrying a single parameter. */
const pushVaMessage = (parameter: any) => {
cy.window().then((win) => {
win.postMessage(
{
version: '1',
resultName: 'dd1',
rowCount: 0,
availableRowCount: 0,
data: [],
columns: [],
parameters: [parameter]
},
'*'
)
})
}
// 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) => {
// `extraParams` is appended to that query string (e.g. va_parmap entries).
const openEditorInVaMode = (
tablename: string,
callback?: any,
extraParams?: string
) => {
openTableFromTree(libraryToOpenIncludes, tablename)
cy.get('#hotTable', { timeout: longerCommandTimeout })
@@ -84,7 +161,13 @@ const openEditorInVaMode = (tablename: string, callback?: any) => {
.then(() => {
cy.url().then((url) => {
const separator = url.includes('?') ? '&' : '?'
cy.visit(`${url}${separator}embed=va`)
const extra = extraParams ? `&${extraParams}` : ''
cy.visit(`${url}${separator}embed=va${extra}`)
// A cy.visit that differs from the current URL only in the hash is a
// same-document navigation, so the app is not re-bootstrapped and the
// embed parameter (read once at app init) never takes effect. Reload to
// bootstrap it, as the VA iframe load does.
cy.reload()
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
'not.exist'
)
+9
View File
@@ -61,6 +61,14 @@ export const globals: {
* Defaults to 'live'; toggled by the Auto-apply checkbox in the editor.
*/
vaApplyMode: 'live' | 'confirm'
/**
* Explicit VA parameter -> DC column mappings, parsed from the `va_parmap`
* URL parameter (see parseVaParmap). Empty unless configured; entries override
* VA's default same-name (parameter label -> column) resolution, so two DC
* instances over one table can filter the same column from differently-named
* VA parameters.
*/
vaParamMap: Map<string, string>
rootParam: string
dcLib: string
xlmaps: XLMapListItem[]
@@ -77,6 +85,7 @@ export const globals: {
} = {
embed: false,
vaApplyMode: 'live',
vaParamMap: new Map(),
rootParam: <string>'',
dcLib: '',
xlmaps: [],
+7
View File
@@ -12,6 +12,7 @@ import '@clr/icons'
import '@clr/icons/shapes/all-shapes'
import { globals } from './_globals'
import { parseEmbedParam } from './shared/utils/parse-embed-param'
import { parseVaParmap } from './shared/utils/parse-va-parmap'
import moment from 'moment'
import { EventService } from './services/event.service'
import { AppService } from './services/app.service'
@@ -163,6 +164,12 @@ export class AppComponent {
globals.embed = embed
this.embed = embed
}
// VA parameter -> DC column mappings for this instance, held on globals
// (like `embed` and `vaApplyMode`) so in-app navigation that rebuilds the
// editor keeps them - e.g. returning from the stage / approve pages.
if (globals.embed === 'va') {
globals.vaParamMap = parseVaParmap(hashQuery ?? '')
}
this.subscribeToShowAbortModal()
this.subscribeToRequestsModal()
+15 -2
View File
@@ -485,6 +485,14 @@ export class EditorComponent
get isVaEmbed() {
return globals.embed === 'va'
}
/**
* Explicit VA parameter -> DC column mappings (`va_parmap`) for this instance.
* Parsed once at app init (see parseVaParmap) and held on globals so the
* editor reloads a filter apply performs keep using the same mapping.
*/
get vaParamMap(): Map<string, string> {
return globals.vaParamMap
}
/**
* VA filter mode. Live = auto-apply each VA change; confirm = stage the change
* and wait for the user to click Apply. Backed by globals (toggled by the
@@ -3602,7 +3610,7 @@ export class EditorComponent
const msg = this.vaMessaging.latestMessage()
if (!msg) return false
const clauses = this.vaFilter.buildInitialClauses(msg)
const clauses = this.vaFilter.buildInitialClauses(msg, this.vaParamMap)
if (clauses.length === 0) return false
try {
@@ -5048,7 +5056,12 @@ export class EditorComponent
this.updateVaFilterText(msg)
const clauses = this.vaFilter.buildClauses(msg, labelToVar, this.cols)
const clauses = this.vaFilter.buildClauses(
msg,
labelToVar,
this.cols,
this.vaParamMap
)
const signature = this.vaFilter.signature(clauses)
if (signature === this.vaMessaging.filterSignature) {
@@ -199,7 +199,139 @@ describe('VaFilterService', () => {
})
})
describe('buildClauses with an explicit va_parmap', () => {
const build = (parameters: any[], paramMap: Map<string, string>) =>
service.buildClauses(msg(parameters), labelToVar(), cols, paramMap)
it('overrides the same-name match, including the value formatting', () => {
// The VA label IS a DC column here (SOME_CHAR), but the mapping points at
// a different one - the numeric column - so the value is formatted numeric.
const map = new Map([['some_char', 'SOME_NUM']])
expect(build([{ label: 'SOME_CHAR', value: '10' }], map)).toEqual([
{
GROUP_LOGIC: 'AND',
SUBGROUP_LOGIC: 'AND',
SUBGROUP_ID: 0,
VARIABLE_NM: 'SOME_NUM',
OPERATOR_NM: '=',
RAW_VALUE: '10'
}
])
})
it('lets two differently-named params filter the same column', () => {
// The multi-tab case: one DC instance per report tab, each with its own
// VA parameter, both mapping to the same DC column.
const map = new Map([
['region_tab1', 'SOME_CHAR'],
['region_tab2', 'SOME_CHAR']
])
const clauses = build(
[
{ label: 'REGION_TAB1', value: 'north' },
{ label: 'REGION_TAB2', value: 'south' }
],
map
)
expect(clauses.map((c) => [c.VARIABLE_NM, c.RAW_VALUE])).toEqual([
['SOME_CHAR', "'north'"],
['SOME_CHAR', "'south'"]
])
})
it('matches the mapping key against the param name too', () => {
const map = new Map([['pr273', 'SOME_NUM']])
expect(
build([{ name: 'pr273', label: 'Tab_1_Value', value: '7' }], map)[0]
.VARIABLE_NM
).toBe('SOME_NUM')
// Label wins when both are given, as in label->column matching.
const both = new Map([
['pr273', 'SOME_NUM'],
['tab_1_value', 'SOME_CHAR']
])
expect(
build([{ name: 'pr273', label: 'Tab_1_Value', value: 'x' }], both)[0]
.VARIABLE_NM
).toBe('SOME_CHAR')
})
it('uses the column case from the loaded metadata', () => {
const map = new Map([['region_tab1', 'some_char']])
expect(
build([{ label: 'REGION_TAB1', value: 'x' }], map)[0].VARIABLE_NM
).toBe('SOME_CHAR')
})
it('leaves unmapped params on the same-name path', () => {
const map = new Map([['region_tab1', 'SOME_NUM']])
const clauses = build(
[
{ label: 'REGION_TAB1', value: '10' },
{ label: 'SOME_CHAR', value: 'x' }
],
map
)
expect(clauses.map((c) => [c.VARIABLE_NM, c.RAW_VALUE])).toEqual([
['SOME_NUM', '10'],
['SOME_CHAR', "'x'"]
])
})
it('skips a mapping whose column is not in this table (no fallback)', () => {
// Falling back to the same-name match would silently apply the filter the
// mapping exists to override, so the param is dropped instead.
const map = new Map([['some_char', 'NOT_IN_THIS_TABLE']])
expect(build([{ label: 'SOME_CHAR', value: 'x' }], map)).toEqual([])
})
it('behaves exactly as before with an empty map', () => {
expect(
service.buildClauses(
msg([{ label: 'SOME_CHAR', value: 'x' }]),
labelToVar(),
cols
)
).toEqual(build([{ label: 'SOME_CHAR', value: 'x' }], new Map()))
})
})
describe('buildInitialClauses (no metadata, infer kind from value)', () => {
it('uses an explicit mapping instead of the label', () => {
// The deferred first load has no column metadata, so a mapped column is
// used as given and the SAS kind still comes from the value shape.
const map = new Map([
['region_tab1', 'REGION'],
['amt_tab1', 'AMOUNT']
])
const clauses = service.buildInitialClauses(
msg([
{ name: 'pr1', label: 'REGION_TAB1', dataType: 'string', value: 'n' },
{ name: 'pr2', label: 'AMT_TAB1', dataType: 'number', value: '10' }
]),
map
)
expect(clauses.map((c) => [c.VARIABLE_NM, c.RAW_VALUE])).toEqual([
['REGION', "'n'"],
['AMOUNT', '10']
])
})
it('falls back to the label for params the map does not name', () => {
const map = new Map([['region_tab2', 'REGION']])
const clauses = service.buildInitialClauses(
msg([
{ name: 'pr1', label: 'REGION_TAB1', value: 'n' },
{ name: 'pr2', label: 'REGION_TAB2', value: 's' }
]),
map
)
expect(clauses.map((c) => [c.VARIABLE_NM, c.RAW_VALUE])).toEqual([
['REGION_TAB1', "'n'"],
['REGION', "'s'"]
])
})
it('uses the VA label as the column and infers kinds by value shape', () => {
const clauses = service.buildInitialClauses(
msg([
+63 -8
View File
@@ -54,11 +54,19 @@ export class VaFilterService {
* Translates VA `parameters` into DC filter clauses. Each parameter is matched
* to a DC column by label; a single value becomes `col = value`, multiple
* values become `col IN (...)`. Values are formatted for the column's SAS kind.
*
* `paramMap` (from the URL `va_parmap` parameter, see parseVaParmap) overrides
* that same-name resolution for the parameters it names, so two DC instances
* over one table can filter the same column from differently-named VA
* parameters. A mapped column must exist in `cols` - if it does not the
* parameter is skipped rather than falling back to the same-name match, which
* is exactly the behaviour the mapping exists to override.
*/
buildClauses(
msg: VaMessage,
labelToVar: Map<string, string>,
cols: any[]
cols: any[],
paramMap?: Map<string, string>
): QueryClause[] {
const clauses: QueryClause[] = []
for (const param of msg.parameters || []) {
@@ -67,12 +75,21 @@ export class VaFilterService {
.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)
// An explicit URL mapping wins over the label->column lookup. The mapped
// column has to exist in THIS table, else the entry does not apply here
// (and the same-name fallback would be the behaviour it exists to
// override).
const mapped = this.mappedColumn(param, paramMap)
const mappedCol = mapped ? this.findCol(mapped, cols) : undefined
if (mapped && !mappedCol) continue
// The table's own case, so the clause matches what the metadata build
// produces for a same-name match.
const varName: string = mappedCol
? mappedCol.NAME
: (labelToVar.get(key) ?? '')
if (!varName) continue
const col = this.findCol(varName, cols)
const col = mappedCol ?? this.findCol(varName, cols)
const kind = this.columnKind(col)
const order = this.dateOrderFromFmt(col?.FMTNAME || col?.FORMAT)
const rawValues = Array.isArray(param.value) ? param.value : [param.value]
@@ -96,13 +113,27 @@ export class VaFilterService {
* metadata isn't available. Uses the VA `label` as the column name and infers
* the SAS kind from the value shape, so the clause matches what the metadata
* build later produces (no reconcile reload).
*
* `paramMap` overrides the label-as-column rule for the parameters it names,
* so the very first (already filtered) load also honours an explicit mapping.
*/
buildInitialClauses(msg: VaMessage): QueryClause[] {
buildInitialClauses(
msg: VaMessage,
paramMap?: Map<string, string>
): 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()
// Without a mapping the DC column name is the VA `label`; `name` is VA's
// internal id (pr###). A `va_parmap` entry wins over both.
const varName = (
this.mappedColumn(param, paramMap) ??
param.label ??
param.name ??
''
)
.toString()
.trim()
if (!varName) continue
const rawValues = Array.isArray(param.value) ? param.value : [param.value]
@@ -153,6 +184,30 @@ export class VaFilterService {
}
}
/**
* The DC column an explicit URL mapping (`va_parmap`) assigns to this
* parameter, or undefined when the parameter is not mapped. The key is matched
* case-insensitively against the parameter's `label` and then its `name`, the
* same dual key the label->column map uses, so a mapping can be written with
* whichever of the two is stable in the report.
*/
private mappedColumn(
param: any,
paramMap?: Map<string, string>
): string | undefined {
if (!paramMap || paramMap.size === 0) return undefined
const keys: string[] = []
for (const candidate of [param.label, param.name]) {
const key = (candidate ?? '').toString().trim().toLowerCase()
if (key && !keys.includes(key)) keys.push(key)
}
for (const key of keys) {
const column = paramMap.get(key)
if (column) return column
}
return undefined
}
/** The loaded column spec whose NAME matches varName (case-insensitive). */
private findCol(varName: string, cols: any[]): any {
return (cols || []).find(
@@ -0,0 +1,60 @@
import { parseVaParmap } from './parse-va-parmap'
describe('parseVaParmap', () => {
it('returns an empty map when the parameter is absent', () => {
expect(parseVaParmap('').size).toBe(0)
expect(parseVaParmap('embed=va').size).toBe(0)
expect(parseVaParmap('embed=va&labels=true').size).toBe(0)
})
it('maps a VA parameter label to a DC column', () => {
const map = parseVaParmap('embed=va&va_parmap=REGION_TAB1-REGION')
expect(map.get('region_tab1')).toBe('REGION')
expect(map.size).toBe(1)
})
it('collects repeated entries, so two parameters can map to one column', () => {
// The multi-tab case: each DC instance filters the same DC column from a
// differently-named VA parameter.
const map = parseVaParmap(
'embed=va&va_parmap=REGION_TAB1-REGION&va_parmap=REGION_TAB2-REGION'
)
expect(map.get('region_tab1')).toBe('REGION')
expect(map.get('region_tab2')).toBe('REGION')
expect(map.size).toBe(2)
})
it('is case-insensitive on the key and trims both sides', () => {
const map = parseVaParmap('va_parmap=%20Region_Tab1%20-%20region%20')
expect(map.get('region_tab1')).toBe('region')
})
it('decodes a key containing a space (VA labels are free text)', () => {
expect(parseVaParmap('va_parmap=My+Region-DEPT').get('my region')).toBe(
'DEPT'
)
})
it('splits at the LAST hyphen, so a label may contain hyphens', () => {
expect(parseVaParmap('va_parmap=Sales-2024-DEPT').get('sales-2024')).toBe(
'DEPT'
)
})
it('drops entries with no separator or an empty side', () => {
expect(parseVaParmap('va_parmap=DEPT').size).toBe(0)
expect(parseVaParmap('va_parmap=-DEPT').size).toBe(0)
expect(parseVaParmap('va_parmap=REGION_TAB1-').size).toBe(0)
expect(parseVaParmap('va_parmap=').size).toBe(0)
})
it('drops a column that is not a plain SAS name (injection guard)', () => {
// The column is interpolated into a SAS WHERE clause, so anything beyond a
// SAS name is refused at parse time rather than carried into a clause.
expect(parseVaParmap('va_parmap=A-x;drop').size).toBe(0)
expect(parseVaParmap('va_parmap=A-REGION%20OR%201=1').size).toBe(0)
expect(parseVaParmap("va_parmap=A-RE'GION").size).toBe(0)
expect(parseVaParmap('va_parmap=A-1ABC').size).toBe(0)
expect(parseVaParmap('va_parmap=A-A_B9').get('a')).toBe('A_B9')
})
})
@@ -0,0 +1,45 @@
/**
* Parses the `va_parmap` entries out of a hash query string (the part after `?`
* in `window.location.hash`).
*
* Format - one mapping per occurrence, each `<VA parameter key>-<DC column>`:
*
* ?embed=va&va_parmap=REGION_TAB1-REGION&va_parmap=REGION_TAB2-REGION
*
* Repeating the parameter (rather than packing a comma-separated list into one
* value) keeps the grammar unambiguous: VA parameter labels are free text, so
* any list separator we picked could also appear inside a label.
*
* Each entry is split at its LAST `-`. A SAS column name can never contain a
* hyphen, so the final one always delimits the column, and a label that does
* contain hyphens (e.g. `Sales-2024`) still maps correctly.
*
* The key is matched case-insensitively, and trailing/leading blanks are
* trimmed, against a VA parameter's `label` and then its `name` (VA's internal
* id, e.g. `pr273`) - the same dual key that label->column matching uses.
*
* Returns an empty map when the parameter is absent, which leaves the default
* same-name resolution untouched (existing embeds are unaffected).
*
* Modeled on `parseEmbedParam` / `parseLabelsParam`, which read the same hash
* query.
*/
export function parseVaParmap(hashQuery: string): Map<string, string> {
const map = new Map<string, string>()
if (!hashQuery) return map
for (const entry of new URLSearchParams(hashQuery).getAll('va_parmap')) {
// Last hyphen delimits the DC column (see above).
const separator = (entry ?? '').lastIndexOf('-')
if (separator <= 0) continue
const key = entry.slice(0, separator).trim().toLowerCase()
const column = entry.slice(separator + 1).trim()
if (!key) continue
// The column name is interpolated into a SAS WHERE clause, so only a plain
// SAS name is accepted - anything else is dropped rather than mapped.
if (!/^[A-Za-z_][A-Za-z0-9_]{0,31}$/.test(column)) continue
map.set(key, column)
}
return map
}