A selection over DC_MAXOBS_WEBEDIT or DC_MAXCELLS_WEBEDIT was refused with "Table is too big", leaving the user with no grid and no way to see what the filter had matched. getdata now serves the first rows that fit and flags it: - the row cap is min(DC_MAXOBS_WEBEDIT, DC_MAXCELLS_WEBEDIT / columns), so a wide table is still bounded by the cell budget - sasparams carries TRUNCATED (0/1) and TRUNC_MSG, naming the limit reached - the EDIT screen shows a warning triangle next to the table name, with the message in a tooltip and the advice to filter The mock mirrors the new behaviour, the Cypress spec asserts the truncated grid and the tooltip (real hover, opacity checked so a hidden tooltip cannot pass), and the SAS limit test now asserts the served row counts and message rather than a cancelled child job.
447 lines
15 KiB
TypeScript
447 lines
15 KiB
TypeScript
// The EDIT screen is bounded by DC_MAXOBS_WEBEDIT rows and DC_MAXCELLS_WEBEDIT
|
|
// cells (rows x columns), whichever is reached first, and the VIEW screen caps
|
|
// at DC_MAXOBS_WEBVIEW rows.
|
|
//
|
|
// A selection over an EDIT limit is served TRUNCATED - the grid carries the
|
|
// first rows that fit, and a warning triangle against the table name says why.
|
|
// This spec pins those limits against the mock, which mirrors the shipped
|
|
// defaults (250 rows / 200000 cells for EDIT, 2000 rows for VIEW). It uses
|
|
// two existing fixtures rather than new ones:
|
|
// MPE_X_SEARCH - 1000 rows x 9 cols, so it trips the ROW limit
|
|
// MPE_X_WIDE - 200 rows x 1001 cols (200,200 cells), under the row limit
|
|
// but over the CELL limit, so the row cap is 199
|
|
export {}
|
|
|
|
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}`)
|
|
|
|
context('EDIT row and cell limits, VIEW row limit: ', function () {
|
|
this.beforeAll(() => {
|
|
cy.visit(`${hostUrl}/SASLogon/logout`)
|
|
// forceLicenceKey=true: applies a valid licence rather than relying on an
|
|
// earlier spec in the run having done so (see commands.ts).
|
|
cy.loginAndUpdateValidKey(true)
|
|
})
|
|
|
|
this.beforeEach(() => {
|
|
cy.visit(hostUrl + appLocation)
|
|
visitPage('home')
|
|
visitPage('view/data')
|
|
})
|
|
|
|
it('1 | VIEW renders up to the raised row cap, past the old 500', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_search')
|
|
|
|
// Neither the header nor the cell text can pin the cap: the header reports
|
|
// the table's true row count whether or not the grid is capped, and the
|
|
// fixture's own numeric columns carry values above 1500 (DEPTH_M reaches
|
|
// 4293, SAMPLE_COUNT 4210), so a text match can hit fixture data.
|
|
//
|
|
// The PK can pin it. The first td of a .ht_master row is the PK - the row
|
|
// header, where present, is a th - and MPE_X_SEARCH holds PK 1001-2000, so
|
|
// the range check below is what stops a mis-selected column passing. With
|
|
// the old 500 row cap the grid stopped at PK 1500, so a rendered PK above
|
|
// 1500 can only appear if the cap was raised.
|
|
scrollGridToBottom()
|
|
cy.get('#hotTable .ht_master .htCore tbody tr', {
|
|
timeout: longerCommandTimeout
|
|
}).should(($rows) => {
|
|
const pks = $rows
|
|
.toArray()
|
|
.map((row) => row.querySelectorAll('td')[0])
|
|
.filter((td) => !!td)
|
|
.map((td) => parseInt((td.textContent || '').trim(), 10))
|
|
.filter((pk) => !isNaN(pk))
|
|
|
|
expect(pks.length).to.be.greaterThan(0)
|
|
pks.forEach((pk) => expect(pk).to.be.within(1001, 2000))
|
|
expect(Math.max(...pks)).to.be.greaterThan(1500)
|
|
})
|
|
})
|
|
|
|
it('2 | EDIT serves a table over the row limit, truncated, with a warning', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_search')
|
|
openEdit()
|
|
|
|
// 1000 rows x 9 columns is over the 250 row limit, so the grid carries the
|
|
// first 250 rows and the header says how many it is showing.
|
|
cy.get('.editor-title', { timeout: longerCommandTimeout }).should(
|
|
'contain.text',
|
|
'250 rows'
|
|
)
|
|
cy.get('#hotTable .ht_master .htCore tbody tr', {
|
|
timeout: longerCommandTimeout
|
|
}).should('have.length.at.least', 1)
|
|
|
|
cy.get('.truncation-warning', { timeout: longerCommandTimeout })
|
|
.should('be.visible')
|
|
.realHover()
|
|
cy.get('.editor-title clr-tooltip-content', {
|
|
timeout: longerCommandTimeout
|
|
})
|
|
.should('be.visible')
|
|
.should('contain.text', 'DC_MAXOBS_WEBEDIT')
|
|
.should(($el) => expect($el.css('opacity')).to.eq('1'))
|
|
})
|
|
|
|
it('3 | EDIT serves a table over the cell limit, truncated, with a warning', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_wide')
|
|
openEdit()
|
|
|
|
// 200 rows x 1001 stored columns = 200,200 cells, over the 200,000 cell
|
|
// limit, while the row count is under the 250 row limit - so the cell
|
|
// limit sets the cap: floor(200000 / 1001) = 199 rows.
|
|
cy.get('.editor-title', { timeout: longerCommandTimeout }).should(
|
|
'contain.text',
|
|
'199 rows'
|
|
)
|
|
cy.get('.truncation-warning', { timeout: longerCommandTimeout })
|
|
.should('be.visible')
|
|
.realHover()
|
|
cy.get('.editor-title clr-tooltip-content', {
|
|
timeout: longerCommandTimeout
|
|
})
|
|
.should('be.visible')
|
|
.should('contain.text', 'DC_MAXCELLS_WEBEDIT')
|
|
.should(($el) => expect($el.css('opacity')).to.eq('1'))
|
|
})
|
|
|
|
it('4 | the EDIT limits follow MPE_CONFIG, not the shipped defaults', () => {
|
|
// Two things at once. The seeded config carries the shipped defaults (250
|
|
// rows / 200000 cells), which are the numbers the mock used to hard-code -
|
|
// so nothing above would notice if the mock stopped reading MPE_CONFIG at
|
|
// all. And MPE_CONFIG lives in the control library, so reading it from the
|
|
// table's own library would fail for this table in particular.
|
|
//
|
|
// The cell limit is the one to move: 7 rows x 17 columns is 119 cells, so
|
|
// at 110 the TESTDATA.MPE_X_NEW edit must be truncated to
|
|
// floor(110 / 17) = 6 rows, where a hard-coded 200000 - or a config read
|
|
// that fails and falls back to it - lets it through. 110 is still above
|
|
// MPE_CONFIG's own 6 x 17 = 102, so the option can be put back afterwards.
|
|
setConfigValue('DC_MAXCELLS_WEBEDIT', '110', () => {
|
|
visitPage('view/data/TESTDATA.MPE_X_NEW')
|
|
closeAbortModalIfPresent()
|
|
openEdit()
|
|
|
|
cy.get('.editor-title', { timeout: longerCommandTimeout }).should(
|
|
'contain.text',
|
|
'6 rows'
|
|
)
|
|
cy.get('.truncation-warning', { timeout: longerCommandTimeout })
|
|
.should('be.visible')
|
|
.realHover()
|
|
cy.get('.editor-title clr-tooltip-content', {
|
|
timeout: longerCommandTimeout
|
|
})
|
|
.should('be.visible')
|
|
.should('contain.text', 'DC_MAXCELLS_WEBEDIT')
|
|
.should(($el) => expect($el.css('opacity')).to.eq('1'))
|
|
})
|
|
|
|
// Put the option back, so the rest of the estate sees the shipped default.
|
|
setConfigValue('DC_MAXCELLS_WEBEDIT', '200000', () => {
|
|
visitPage('view/data/TESTDATA.MPE_X_NEW')
|
|
closeAbortModalIfPresent()
|
|
openEdit()
|
|
|
|
// 119 cells under a 200000 cell limit: all 7 rows load, no warning.
|
|
cy.get('.editor-title', { timeout: longerCommandTimeout }).should(
|
|
'contain.text',
|
|
'7 rows'
|
|
)
|
|
cy.get('.truncation-warning').should('not.exist')
|
|
})
|
|
})
|
|
|
|
it('5 | the warning says to filter, and filtering narrows the grid', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_search')
|
|
openEdit()
|
|
|
|
cy.get('.truncation-warning', { timeout: longerCommandTimeout })
|
|
.should('be.visible')
|
|
.realHover()
|
|
cy.get('.editor-title clr-tooltip-content', {
|
|
timeout: longerCommandTimeout
|
|
})
|
|
.should('be.visible')
|
|
.should('contain.text', 'Filter the table to work with the rest')
|
|
.should(($el) => expect($el.css('opacity')).to.eq('1'))
|
|
|
|
// The advice has to be actionable: the picker must offer the table's
|
|
// columns and the filter must reload the grid.
|
|
openFilterPopup()
|
|
|
|
filterVariables().should(($options) => {
|
|
const names = $options
|
|
.toArray()
|
|
.map((o) => (o.textContent || '').trim())
|
|
.filter((n) => n.length > 0)
|
|
|
|
expect(names, 'the picker offers the table columns').to.include.members([
|
|
'PRIMARY_KEY_FIELD',
|
|
'SITE_NAME',
|
|
'VESSEL',
|
|
'SAMPLE_COUNT',
|
|
'DEPTH_M',
|
|
'SPECIES',
|
|
'CRUISE_DATE',
|
|
'EXPEDITION_ID',
|
|
'NOTES'
|
|
])
|
|
})
|
|
|
|
setFilterVariable('SITE_NAME')
|
|
// 100 of the 1000 rows: under the limit, so nothing is truncated.
|
|
setFilterValue('Nereid Trench')
|
|
submitFilter()
|
|
|
|
cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should(
|
|
'contain.text',
|
|
"SITE_NAME = 'Nereid Trench'"
|
|
)
|
|
cy.get('.editor-title', { timeout: longerCommandTimeout }).should(
|
|
'contain.text',
|
|
'100 rows'
|
|
)
|
|
cy.get('.truncation-warning').should('not.exist')
|
|
})
|
|
})
|
|
|
|
const visitPage = (url: string) => {
|
|
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
|
|
}
|
|
|
|
// The viewer's options dropdown holds the Edit item; it only renders for a
|
|
// table that is registered in MPE_TABLES (tableEditExists()).
|
|
const openEdit = () => {
|
|
cy.get('.filterSide', { timeout: longerCommandTimeout }).click()
|
|
cy.contains('[clrDropdownItem]', 'Edit', {
|
|
timeout: longerCommandTimeout
|
|
}).click()
|
|
}
|
|
|
|
/**
|
|
* The EDIT screen's Filter button opens the same .filter-modal the viewer uses.
|
|
* It only renders once the editor is in its read-only (non-editing) state,
|
|
* which is where a failed EDIT load leaves it.
|
|
*/
|
|
const openFilterPopup = () => {
|
|
cy.contains('button.btnView', 'Filter', {
|
|
timeout: longerCommandTimeout
|
|
}).click()
|
|
cy.get('.filter-modal', { timeout: longerCommandTimeout }).should(
|
|
'be.visible'
|
|
)
|
|
}
|
|
|
|
/** The picker's variable suggestion list. */
|
|
const filterVariables = () =>
|
|
cy.get('#datalist_vals_var_id0_0 option', { timeout: longerCommandTimeout })
|
|
|
|
/** The soft-select drops a transparent overlay over the modal, so it needs force. */
|
|
const setFilterVariable = (column: string) => {
|
|
cy.get('#vals_var_id0_0').clear({ force: true }).type(column, { force: true })
|
|
cy.get('#datalist_vals_var_id0_0 option').contains(column).click({
|
|
force: true
|
|
})
|
|
cy.get('#vals_var_id0_0').trigger('keyup', { key: 'Escape', force: true })
|
|
}
|
|
|
|
/** The value input of the same soft-select row. */
|
|
const setFilterValue = (value: string) => {
|
|
cy.get('#vals_0_0').clear({ force: true }).type(value, { force: true })
|
|
cy.get('#vals_0_0').trigger('keyup', { key: 'Escape', force: true })
|
|
cy.wait(400)
|
|
}
|
|
|
|
const submitFilter = () => {
|
|
cy.contains('.filter-modal button', 'Ok').click()
|
|
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
|
|
cy.wait(2500)
|
|
}
|
|
|
|
/**
|
|
* Sets a DC option in MPE_CONFIG through the editor and approves it, then hands
|
|
* back to the callback. The same route licensing.cy.ts uses for the licence
|
|
* keys: the config table needs an approval like any other.
|
|
*/
|
|
const setConfigValue = (
|
|
varName: string,
|
|
varValue: string,
|
|
callback: () => void
|
|
) => {
|
|
reloadApp()
|
|
// straight to the config table's editor, so the flow does not depend on
|
|
// whichever view mode the previous step left behind
|
|
visitPage('editor/DC_JSLIB.MPE_CONFIG')
|
|
closeAbortModalIfPresent()
|
|
clickOnEdit()
|
|
|
|
editTableField([{ varName, varValue }], () => {
|
|
submitTable(() => {
|
|
cy.wait(2000)
|
|
approveTable(() => {
|
|
reloadApp()
|
|
callback()
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* A full page load. A visit that only changes the hash does not reload the
|
|
* app, so an open modal - the abort message from the step just gone, say -
|
|
* would still be covering the page.
|
|
*/
|
|
const reloadApp = () => {
|
|
cy.visit(hostUrl + appLocation)
|
|
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
|
|
}
|
|
|
|
/**
|
|
* DC puts abort messages up as modals over whatever page is showing, and they
|
|
* outlive a hash-only navigation, so clear one before driving the page.
|
|
*/
|
|
const closeAbortModalIfPresent = () => {
|
|
cy.get('body').then(($body) => {
|
|
if ($body.find('app-info-modal').length > 0) {
|
|
cy.get('app-info-modal')
|
|
.contains('button', 'Close')
|
|
.click({ force: true })
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* The viewer's Edit button. The options dropdown (.filterSide) that openEdit()
|
|
* uses is not rendered for every table, so the config table is opened the way
|
|
* licensing.cy.ts opens it.
|
|
*/
|
|
const clickOnEdit = (callback?: () => void) => {
|
|
cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout })
|
|
.click()
|
|
.then(() => {
|
|
if (callback) callback()
|
|
})
|
|
}
|
|
|
|
/** Types a new value into the row whose cell carries varName. */
|
|
const editTableField = (
|
|
edits: { varName: string; varValue: string }[],
|
|
callback: () => void
|
|
) => {
|
|
cy.get('#hotTable .ht_master .htCore tbody td', {
|
|
timeout: longerCommandTimeout
|
|
}).then(($tds: any) => {
|
|
const nodes = $tds.toArray()
|
|
|
|
for (const edit of edits) {
|
|
for (let i = 0; i < nodes.length; i++) {
|
|
// exact match on the cell that holds the option name; the cell after it
|
|
// is the value
|
|
if ((nodes[i].textContent || '').trim() === edit.varName) {
|
|
cy.wrap(nodes[i + 1])
|
|
.dblclick()
|
|
.then(() => {
|
|
cy.focused()
|
|
.type('{selectall}')
|
|
.type(edit.varValue)
|
|
.type('{enter}')
|
|
})
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
callback()
|
|
})
|
|
}
|
|
|
|
const submitTable = (callback: () => void) => {
|
|
cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout })
|
|
.click()
|
|
.then(() => {
|
|
cy.get(".modal.ng-star-inserted button[type='submit']").click()
|
|
callback()
|
|
})
|
|
}
|
|
|
|
const approveTable = (callback: () => void) => {
|
|
cy.get('button', { timeout: longerCommandTimeout })
|
|
.should('contain', 'Approve')
|
|
.then((allButtons: any) => {
|
|
for (const approvalButton of allButtons) {
|
|
if (approvalButton.innerText.toLowerCase().includes('approve')) {
|
|
approvalButton.click()
|
|
break
|
|
}
|
|
}
|
|
|
|
cy.get('button#acceptBtn', { timeout: longerCommandTimeout })
|
|
.should('exist')
|
|
.should('not.be.disabled')
|
|
.click()
|
|
.then(() => {
|
|
cy.get('app-history', { timeout: longerCommandTimeout }).should(
|
|
'exist'
|
|
)
|
|
callback()
|
|
})
|
|
})
|
|
}
|
|
|
|
// Handsontable virtualises rows, so the last row is only in the DOM once the
|
|
// grid has been scrolled to the bottom. The scroller is the .wtHolder inside
|
|
// the .ht_master pane.
|
|
const scrollGridToBottom = () => {
|
|
cy.get('#hotTable .ht_master .wtHolder', { timeout: longerCommandTimeout })
|
|
.scrollTo('bottom', { duration: 0 })
|
|
.trigger('scroll')
|
|
}
|
|
|
|
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 targetLib
|
|
|
|
for (let node of treeNodes) {
|
|
if (node.innerText.toLowerCase().includes(libNameIncludes)) {
|
|
targetLib = node
|
|
break
|
|
}
|
|
}
|
|
|
|
cy.get(targetLib).within(() => {
|
|
// Small settle wait: right after a fresh visit the tree component can
|
|
// still be re-rendering, causing this node to be found then swapped
|
|
// out mid-click.
|
|
cy.wait(300)
|
|
|
|
cy.get(
|
|
'.clr-tree-node-content-container .clr-treenode-content p'
|
|
).click()
|
|
|
|
cy.get('.clr-treenode-link').then((innerNodes: any) => {
|
|
for (let innerNode of innerNodes) {
|
|
if (innerNode.innerText.toLowerCase().includes(tablename)) {
|
|
innerNode.click()
|
|
break
|
|
}
|
|
}
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
cy.get('#hotTable .ht_clone_top .htCore thead button.changeType', {
|
|
timeout: longerCommandTimeout
|
|
}).should('exist')
|
|
}
|