fix(query): isolate viewbox filter state from the base table's
Build / Build-and-ng-test (pull_request) Successful in 5m8s
Build / Build-and-test-development (pull_request) Successful in 15m11s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m21s

Also guards $dataformats.vars[column] in viewboxes.component.html against
a column not present in the map (unrelated crash hit while reproducing).
This commit is contained in:
YuryShkoda
2026-07-24 12:22:34 +03:00
parent d881290618
commit 7a35cf4a45
5 changed files with 136 additions and 93 deletions
+77 -54
View File
@@ -333,68 +333,70 @@ context('editor tests: ', function () {
})
})
// We will enable this test when we figure out how to mock filtering
// it('7 | Add viewboxes and filter', () => {
// const viewboxes = ['mpe_x_test', 'mpe_validations']
// The base VIEW table and a viewbox on a different table must never
// share a filter dialog's cached state - each keeps its own entry in
// the globals filter cache (getFilterObjPath), keyed by viewboxId where
// one is present. Opened from the VIEW page specifically
// (rootParam === 'view') - the one branch where this previously broke,
// since a 'view'-page QueryComponent was always treated as the base
// table regardless of viewboxId.
it('7 | Base table filter and viewbox filter stay isolated from each other', () => {
const viewbox_table = 'mpe_audit'
// openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
visitPage('view/data')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
// cy.get('.viewbox-open').click()
// openTableFromViewboxTree(libraryToOpenIncludes, viewboxes)
// Filter the base table.
openViewFilter()
setFilterWithValue('SOME_CHAR', 'this is dummy data', 'value', () => {
cy.get('app-query', { withinSubject: null }).should('not.exist')
// cy.wait(1000)
// Open a viewbox on a *different* table and check its filter dialog
// does not show the base table's filter.
openViewViewboxes()
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
closeViewboxModal()
// closeViewboxModal()
cy.get('.viewboxes-container .viewbox', { withinSubject: null }).then(
(viewboxNodes: any) => {
for (let viewboxNode of viewboxNodes) {
cy.get(viewboxNode).within(() => {
cy.get('.table-title').then((title: any) => {
if (!title[0].innerText.toLowerCase().includes(viewbox_table))
return
// cy.get('.viewboxes-container .viewbox', { withinSubject: null }).then(
// (viewboxNodes: any) => {
// for (let viewboxNode of viewboxNodes) {
// cy.get(viewboxNode).within(() => {
// cy.get('.table-title').then((title: any) => {
// cy.get('.hot-spinner')
// .should('not.exist')
// .then(() => {
// cy.get('clr-icon[shape="filter"]').then((filterButton) => {
// filterButton[0].click()
// })
cy.get('clr-icon[shape="filter"]').click()
// if (title[0].innerText.includes('MPE_X_TEST')) {
// setFilterWithValue(
// 'SOME_CHAR',
// 'this is dummy data',
// 'value',
// () => {
// cy.get('app-query', { withinSubject: null })
// .should('not.exist')
// .get('.ht_master.handsontable tbody tr')
// .then((rowNodes) => {
// const tr = rowNodes[0]
cy.get('.filter-modal code.language-sql', {
withinSubject: null
}).should(($code) => {
expect($code.text()).to.not.contain('SOME_CHAR')
})
// expect(rowNodes).to.have.length(1)
// expect(tr.innerText).to.equal('0')
// })
// }
// )
// } else if (title[0].innerText.includes('MPE_VALIDATIONS')) {
// setFilterWithValue('BASE_COL', 'ALERT_LIB', 'value', () => {
// cy.get('app-query', { withinSubject: null })
// .should('not.exist')
// .get('.ht_master.handsontable tbody tr')
// .then((rowNodes) => {
// const tr = rowNodes[0]
// Reverse direction: filter the viewbox, then confirm
// re-opening the base table's own filter dialog still
// shows the base table's filter, not the viewbox's.
setFilterWithValue('LIBREF', 'sasdemo', 'value', () => {
cy.get('app-query', { withinSubject: null }).should(
'not.exist'
)
// expect(rowNodes).to.have.length(1)
// expect(tr.innerText).to.contain('ALERT_LIB')
// })
// })
// }
// })
// })
// })
// }
// }
// )
// })
openViewFilter()
cy.get('.filter-modal code.language-sql', {
withinSubject: null
}).should(($code) => {
expect($code.text()).to.contain('SOME_CHAR')
expect($code.text()).to.not.contain('LIBREF')
})
})
})
})
}
}
)
})
})
})
const removeAllColumns = () => {
@@ -460,6 +462,27 @@ const addColumns = (columns: string[], callback?: () => void) => {
}
}
// The VIEW page's "options" dropdown (viewer.component.html) - distinct
// from the editor page's dedicated .viewbox-open button used by the
// tests above, since test 7 runs on the VIEW page specifically.
const openViewOptionsDropdown = () => {
cy.get('.filterSide', { withinSubject: null }).click()
}
const openViewFilter = () => {
openViewOptionsDropdown()
cy.get('[clrDropdownItem]', { withinSubject: null })
.contains('Filter')
.click()
}
const openViewViewboxes = () => {
openViewOptionsDropdown()
cy.get('[clrDropdownItem]', { withinSubject: null })
.contains('Viewboxes')
.click()
}
const openViewboxConfig = (viewbox_tablename: string) => {
cy.get('.open-viewbox').then((viewboxes: any) => {
for (let openViewbox of viewboxes) {
+11 -37
View File
@@ -22,6 +22,7 @@ import { QueryDateTime } from './models/QueryDateTime.model'
import { isSpecialMissing } from '@sasjs/utils/input/validators'
import { get } from 'lodash-es'
import { OnLoadingMoreEvent } from '../shared/autocomplete/autocomplete.component'
import { getFilterObjPath } from './utils/getFilterObjPath'
registerLocaleData(localeEnGB)
@Component({
@@ -251,17 +252,7 @@ export class QueryComponent
public setToGlobals() {
if (!this.caching) return
let objPath = ''
if (globals.rootParam === 'home' || globals.rootParam === 'editor') {
if (this.viewboxId) {
objPath = `viewboxes.${this.viewboxId}`
} else {
objPath = 'editor'
}
} else if (globals.rootParam === 'view') {
objPath = 'viewer'
}
const objPath = getFilterObjPath(globals.rootParam, this.viewboxId)
get(globals, objPath).filter.groupLogic = this.groupLogic
if (typeof this.whereClause === 'string') {
@@ -280,17 +271,7 @@ export class QueryComponent
public getFromGlobals() {
if (!this.caching) return
let objPath = ''
if (globals.rootParam === 'home' || globals.rootParam === 'editor') {
if (this.viewboxId) {
objPath = `viewboxes.${this.viewboxId}`
} else {
objPath = 'editor'
}
} else if (globals.rootParam === 'view') {
objPath = 'viewer'
}
const objPath = getFilterObjPath(globals.rootParam, this.viewboxId)
if (get(globals, objPath).filter.cols.length > 0) {
this.cols = JSON.parse(JSON.stringify(get(globals, objPath).filter.cols))
@@ -1051,22 +1032,23 @@ export class QueryComponent
this.columnsSub = this.sasStoreService.columns.subscribe(
(response: any) => {
let cols = response.data.cols
const objPath = getFilterObjPath(globals.rootParam, this.viewboxId)
if (globals.rootParam === 'home' || globals.rootParam === 'editor') {
this.cols = cols
let some = cols[0].NAME
this.libds = response.libds
globals.editor.filter.cols = JSON.parse(JSON.stringify(cols))
get(globals, objPath).filter.cols = JSON.parse(JSON.stringify(cols))
}
if (globals.rootParam === 'view') {
if (globals.viewer.filter.cols.length < 1) {
if (get(globals, objPath).filter.cols.length < 1) {
this.cols = cols
let some = cols[0].NAME
this.libds = response.libds
globals.viewer.filter.cols = JSON.parse(JSON.stringify(cols))
get(globals, objPath).filter.cols = JSON.parse(JSON.stringify(cols))
}
}
@@ -1077,20 +1059,12 @@ export class QueryComponent
)
this.valuesSub = this.sasStoreService.values.subscribe((res: any) => {
if (globals.rootParam === 'home' || globals.rootParam === 'editor') {
if (globals.editor.filter.vals.length < 1) {
this.vals = res.vals
const objPath = getFilterObjPath(globals.rootParam, this.viewboxId)
globals.editor.filter.vals = JSON.parse(JSON.stringify(res.vals))
}
}
if (get(globals, objPath).filter.vals.length < 1) {
this.vals = res.vals
if (globals.rootParam === 'view') {
if (globals.viewer.filter.vals.length < 1) {
this.vals = res.vals
globals.viewer.filter.vals = JSON.parse(JSON.stringify(res.vals))
}
get(globals, objPath).filter.vals = JSON.parse(JSON.stringify(res.vals))
}
})
}
@@ -0,0 +1,31 @@
import { getFilterObjPath } from './getFilterObjPath'
describe('getFilterObjPath', () => {
it("resolves to 'viewer' for the view page with no viewboxId (existing correct case)", () => {
expect(getFilterObjPath('view', undefined)).toEqual('viewer')
})
it("resolves to 'editor' for the editor page with no viewboxId", () => {
expect(getFilterObjPath('editor', undefined)).toEqual('editor')
})
it("resolves to 'editor' for the home page with no viewboxId (existing home/editor grouping)", () => {
expect(getFilterObjPath('home', undefined)).toEqual('editor')
})
it("resolves to 'viewboxes.<id>' when viewboxId is set on the view page - the actual bug (previously returned 'viewer')", () => {
expect(getFilterObjPath('view', 42)).toEqual('viewboxes.42')
})
it("resolves to 'viewboxes.<id>' when viewboxId is set on the editor page (already correct today)", () => {
expect(getFilterObjPath('editor', 42)).toEqual('viewboxes.42')
})
it("resolves to 'viewboxes.<id>' when viewboxId is set on the home page (already correct today)", () => {
expect(getFilterObjPath('home', 42)).toEqual('viewboxes.42')
})
it('resolves to an empty string for an unrecognized rootParam (matches existing fallthrough)', () => {
expect(getFilterObjPath('', undefined)).toEqual('')
})
})
@@ -0,0 +1,15 @@
/**
* Resolves which bucket of the `globals` filter cache (_globals.ts) a
* QueryComponent instance should read/write. viewboxId is checked first,
* unconditionally.
*/
export const getFilterObjPath = (
rootParam: string,
viewboxId?: number
): string => {
if (viewboxId) return `viewboxes.${viewboxId}`
if (rootParam === 'home' || rootParam === 'editor') return 'editor'
if (rootParam === 'view') return 'viewer'
return ''
}
@@ -235,8 +235,8 @@
<span>
{{ column }}
{{
selectedViewboxTable?.hotTable?.$dataformats.vars[column]
.type === 'num'
selectedViewboxTable?.hotTable?.$dataformats?.vars?.[column]
?.type === 'num'
? '(numeric)'
: ''
}}