feat(embed): map VA parameters to DC columns from the URL
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:
1 parent
424b111737
commit
15a1f34d52
8 files changed
+431
-27
No files matched your search
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user