Only backend-sourced `=`-led values are auto-escaped now; anything the user types or pastes into a character column evaluates as a real formula, and "Apply as formula" lets a user promote an auto-escaped cell explicitly. Formulas are enabled on every table instead of only ones with formula rules.
1856 lines
69 KiB
TypeScript
1856 lines
69 KiB
TypeScript
const username = Cypress.env('username')
|
|
const password = Cypress.env('password')
|
|
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}`)
|
|
const fixturePath = 'excels_general/'
|
|
|
|
context('editor tests: ', function () {
|
|
this.beforeAll(() => {
|
|
cy.visit(`${hostUrl}/SASLogon/logout`)
|
|
cy.loginAndUpdateValidKey()
|
|
})
|
|
|
|
this.beforeEach(() => {
|
|
cy.visit(hostUrl + appLocation)
|
|
|
|
visitPage('home')
|
|
})
|
|
|
|
it('1 | Submits duplicate primary keys', (done) => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_datadictionary')
|
|
|
|
attachExcelFile('MPE_DATADICTIONARY_duplicate_keys.xlsx', () => {
|
|
clickOnUploadPreview(() => {
|
|
confirmEditPreviewFile(() => {
|
|
submitTable(() => {
|
|
cy.get('.modal-body').then((modalBody: any) => {
|
|
if (modalBody[0].innerText.includes(`Duplicates found:`)) {
|
|
done()
|
|
}
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
it('2 | Submits null cells which must not be null', (done) => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
cy.get('.ht_master tbody tr').then((rows: any) => {
|
|
cy.get(rows[1].childNodes[2])
|
|
.dblclick({ force: true })
|
|
.then(() => {
|
|
cy.focused()
|
|
.clear()
|
|
.type('{enter}')
|
|
.then(() => {
|
|
submitTable(() => {
|
|
cy.get('.modal-body').then((modalBody: any) => {
|
|
if (
|
|
modalBody[0].innerHTML
|
|
.toLowerCase()
|
|
.includes(`invalid values are present`)
|
|
) {
|
|
done()
|
|
}
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
it('3 | Gets basic dynamic cell validation', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
cy.get('.ht_master tbody tr').then((rows: any) => {
|
|
cy.get(rows[1].childNodes[5])
|
|
.click({ force: true })
|
|
.then(($td) => {
|
|
cy.get('.htAutocompleteArrow', { withinSubject: $td }).should(
|
|
'exist'
|
|
)
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
it('4 | Gets advanced dynamic cell validation', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_tables')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
cy.get('.ht_master tbody tr').then((rows: any) => {
|
|
cy.get(rows[1].childNodes[3])
|
|
.click({ force: true })
|
|
.then(($td) => {
|
|
cy.get('.htAutocompleteArrow', { withinSubject: $td }).should(
|
|
'exist'
|
|
)
|
|
cy.get('.htAutocompleteArrow', {
|
|
withinSubject: rows[1].childNodes[7]
|
|
}).should('exist')
|
|
cy.get('.htAutocompleteArrow', {
|
|
withinSubject: rows[1].childNodes[8]
|
|
}).should('exist')
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
// An invalid cell shouldn't block submission if its row
|
|
// is marked for delete
|
|
it('5 | Submits a delete-marked row with an invalid cell', (done) => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
cy.get('.ht_master tbody tr').then((rows: any) => {
|
|
// childNodes[5] = SOME_NUM (numeric) — feed it a non-numeric value
|
|
// to trigger HOT's native invalid mark.
|
|
cy.get(rows[1].childNodes[5])
|
|
.dblclick({ force: true })
|
|
.then(() => {
|
|
cy.focused()
|
|
.clear()
|
|
.type('not a number{enter}')
|
|
.then(() => {
|
|
// childNodes[1] = _____DELETE__THIS__RECORD_____
|
|
setDeleteFlag(rows[1].childNodes[1], 'Yes', () => {
|
|
submitTable(() => {
|
|
// Validation passed despite the invalid SOME_NUM cell:
|
|
// the confirm-submit modal's Submit button is enabled
|
|
// (validationDone === 1), not the "invalid values" abort.
|
|
cy.get('#submitBtn', {
|
|
timeout: longerCommandTimeout
|
|
})
|
|
.should('exist')
|
|
.should('not.be.disabled')
|
|
.then(() => done())
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
// The delete exemption must not affect the uniqueness (primary key) check.
|
|
it('6 | Still blocks a duplicate primary key even on a delete-marked row', (done) => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
cy.get('.ht_master tbody tr').then((rows: any) => {
|
|
cy.get(rows[0].childNodes[2]).then((firstPk) => {
|
|
const duplicateValue = firstPk.text()
|
|
|
|
// childNodes[2] = PRIMARY_KEY_FIELD
|
|
cy.get(rows[1].childNodes[2])
|
|
.dblclick({ force: true })
|
|
.then(() => {
|
|
cy.focused()
|
|
.clear()
|
|
.type(`${duplicateValue}{enter}`)
|
|
.then(() => {
|
|
setDeleteFlag(rows[1].childNodes[1], 'Yes', () => {
|
|
submitTable()
|
|
|
|
cy.get('.duplicate-keys-modal', {
|
|
timeout: longerCommandTimeout
|
|
})
|
|
.should('exist')
|
|
.then(() => done())
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
// REGEX_HARD_COL/REGEX_SOFT_COL only exist un-stripped on MPE_X_NEW (see
|
|
// RULE_DEMO_COLS in the getdata.js mock) — MPE_X_TEST stays clean to
|
|
// match the excel fixtures.
|
|
it('7 | Blocks submission of a value that fails HARDREGEX', (done) => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
scrollGridRight()
|
|
|
|
getCellByHeaderAndRow(1, 'REGEX_HARD_COL')
|
|
.dblclick({ force: true })
|
|
.then(() => {
|
|
cy.focused()
|
|
.clear()
|
|
.type('not-an-email{enter}')
|
|
.then(() => {
|
|
getCellByHeaderAndRow(1, 'REGEX_HARD_COL').should(
|
|
'have.attr',
|
|
'title',
|
|
'REGEX: /[\\w.]+@[\\w]+\\.[a-z]{2,}/'
|
|
)
|
|
|
|
submitTable(() => {
|
|
cy.get('.modal-body').then((modalBody: any) => {
|
|
if (
|
|
modalBody[0].innerHTML
|
|
.toLowerCase()
|
|
.includes(`invalid values are present`)
|
|
) {
|
|
done()
|
|
}
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
it('8 | Warns (but still submits) a value that fails SOFTREGEX', (done) => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
scrollGridRight()
|
|
|
|
getCellByHeaderAndRow(1, 'REGEX_SOFT_COL')
|
|
.dblclick({ force: true })
|
|
.then(() => {
|
|
cy.focused()
|
|
.clear()
|
|
.type('not a postcode{enter}')
|
|
.then(() => {
|
|
getCellByHeaderAndRow(1, 'REGEX_SOFT_COL')
|
|
.should('have.class', 'dc-warning-cell')
|
|
.and(
|
|
'have.attr',
|
|
'title',
|
|
'REGEX: /[A-Z]{1,2}\\d{1,2}[A-Z]?\\s?\\d[A-Z]{2}/'
|
|
)
|
|
|
|
submitTable(() => {
|
|
// Validation passed despite the SOFTREGEX warning: the
|
|
// confirm-submit modal's Submit button is enabled
|
|
// (validationDone === 1), not the "invalid values" abort.
|
|
cy.get('#submitBtn', { timeout: longerCommandTimeout })
|
|
.should('exist')
|
|
.should('not.be.disabled')
|
|
.then(() => done())
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
// MPE_X_FORMULA_TEST is a small, dedicated fixture for formula testing
|
|
// (kept separate from MPE_X_NEW, which carries no formula columns at
|
|
// all) - row index 1 (0-indexed, i=1 in the mock's row generator) has
|
|
// A_COL=2, B_COL=10: HARDFORMULA (A_COL * B_COL) computes to 20,
|
|
// SOFTFORMULA (A_COL + B_COL) computes to 12.
|
|
it('9 | HARDFORMULA shows the computed value and blocks direct edits', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
getCellByHeaderAndRow(1, 'FORMULA_HARD_COL').should('have.text', '20')
|
|
|
|
getCellByHeaderAndRow(1, 'FORMULA_HARD_COL')
|
|
.dblclick({ force: true })
|
|
.then(() => {
|
|
// readOnly cells don't open an editor - nothing is focused to
|
|
// type into, so this is a no-op if the column is truly readonly.
|
|
cy.focused().type('9999{enter}')
|
|
|
|
getCellByHeaderAndRow(1, 'FORMULA_HARD_COL').should(
|
|
'have.text',
|
|
'20'
|
|
)
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
it('10 | SOFTFORMULA shows the computed default but accepts an override', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').should('have.text', '12')
|
|
|
|
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL')
|
|
.dblclick({ force: true })
|
|
.then(() => {
|
|
cy.focused().clear().type('override{enter}')
|
|
|
|
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').should(
|
|
'have.text',
|
|
'override'
|
|
)
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
it('11 | Info dropdown shows the applied formula as √x=<formula>', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
openColumnDropdown('FORMULA_HARD_COL')
|
|
cy.get('.htDropdownMenu').should(($menu) => {
|
|
expect($menu.text()).to.include('√x=A_COL * B_COL')
|
|
})
|
|
cy.get('body').click(0, 0) // close menu
|
|
|
|
openColumnDropdown('FORMULA_SOFT_COL')
|
|
cy.get('.htDropdownMenu').should(($menu) => {
|
|
expect($menu.text()).to.include('√x=A_COL + B_COL')
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
it('12 | Info dropdown shows the applied HARDREGEX/SOFTREGEX pattern', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
scrollGridRight()
|
|
|
|
openColumnDropdown('REGEX_HARD_COL')
|
|
cy.get('.htDropdownMenu').should(($menu) => {
|
|
expect($menu.text()).to.include(
|
|
'HARDREGEX: /[\\w.]+@[\\w]+\\.[a-z]{2,}/'
|
|
)
|
|
})
|
|
cy.get('body').click(0, 0) // close menu
|
|
|
|
openColumnDropdown('REGEX_SOFT_COL')
|
|
cy.get('.htDropdownMenu').should(($menu) => {
|
|
expect($menu.text()).to.include(
|
|
'SOFTREGEX: /[A-Z]{1,2}\\d{1,2}[A-Z]?\\s?\\d[A-Z]{2}/'
|
|
)
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
it('13 | Info dropdown shows only the applied HARDREGEX pattern when a column has both', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
scrollGridRight()
|
|
|
|
openColumnDropdown('REGEX_BOTH_COL')
|
|
cy.get('.htDropdownMenu').should(($menu) => {
|
|
const text = $menu.text()
|
|
|
|
expect(text).to.include('HARDREGEX: /^[A-Z0-9_-]+$/')
|
|
expect(text).to.not.include('SOFTREGEX: /^.{5,10}$/')
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
it('14 | REGEX_BOTH_COL: a HARDREGEX failure blocks submission and sets its own tooltip, not yellow', (done) => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
scrollGridRight()
|
|
|
|
// 'bad value' fails HARDREGEX (lowercase + space) but is 9 chars,
|
|
// within SOFTREGEX's 5-10 range - isolates the hard-only failure.
|
|
getCellByHeaderAndRow(1, 'REGEX_BOTH_COL')
|
|
.dblclick({ force: true })
|
|
.then(() => {
|
|
cy.focused()
|
|
.clear()
|
|
.type('bad value{enter}')
|
|
.then(() => {
|
|
getCellByHeaderAndRow(1, 'REGEX_BOTH_COL')
|
|
.should('not.have.class', 'dc-warning-cell')
|
|
.and('have.attr', 'title', 'REGEX: /^[A-Z0-9_-]+$/')
|
|
|
|
submitTable(() => {
|
|
cy.get('.modal-body').then((modalBody: any) => {
|
|
if (
|
|
modalBody[0].innerHTML
|
|
.toLowerCase()
|
|
.includes(`invalid values are present`)
|
|
) {
|
|
done()
|
|
}
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
it('15 | REGEX_BOTH_COL: SOFTREGEX is ignored entirely when HARDREGEX is present', (done) => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
scrollGridRight()
|
|
|
|
// 'AB' passes HARDREGEX (uppercase only) but fails SOFTREGEX (too
|
|
// short) - only one regex runs per column, so no warning is shown.
|
|
getCellByHeaderAndRow(1, 'REGEX_BOTH_COL')
|
|
.dblclick({ force: true })
|
|
.then(() => {
|
|
cy.focused()
|
|
.clear()
|
|
.type('AB{enter}')
|
|
.then(() => {
|
|
getCellByHeaderAndRow(1, 'REGEX_BOTH_COL')
|
|
.should('not.have.class', 'dc-warning-cell')
|
|
.and('not.have.attr', 'title')
|
|
|
|
submitTable(() => {
|
|
cy.get('#submitBtn', { timeout: longerCommandTimeout })
|
|
.should('exist')
|
|
.should('not.be.disabled')
|
|
.then(() => done())
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
it("16 | Insert Row above keeps existing rows' formulas aligned with their own shifted data", () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
// Row index 2 (0-indexed): PRIMARY_KEY_FIELD=3, A_COL=3, B_COL=10 ->
|
|
// HARDFORMULA (A_COL*B_COL) = 30, SOFTFORMULA (A_COL+B_COL) = 13.
|
|
getCellByHeaderAndRow(2, 'PRIMARY_KEY_FIELD').should('have.text', '3')
|
|
|
|
insertRowViaContextMenu(2, 'Insert Row above')
|
|
|
|
// The new blank row lands at index 2 - its formula columns are
|
|
// seeded with their own row-relative formula (A_COL/B_COL are both
|
|
// still empty, so HyperFormula evaluates the arithmetic as 0).
|
|
getCellByHeaderAndRow(2, 'FORMULA_HARD_COL').should('have.text', '0')
|
|
getCellByHeaderAndRow(2, 'FORMULA_SOFT_COL').should('have.text', '0')
|
|
|
|
// The row that WAS at index 2 (PK=3) is now shifted down to index
|
|
// 3 - its formulas must recompute from ITS OWN data, not read
|
|
// whatever the engine had cached at index 3 before the insert
|
|
// (that would be PK=4's values: 40/14).
|
|
getCellByHeaderAndRow(3, 'PRIMARY_KEY_FIELD').should('have.text', '3')
|
|
getCellByHeaderAndRow(3, 'FORMULA_HARD_COL').should('have.text', '30')
|
|
getCellByHeaderAndRow(3, 'FORMULA_SOFT_COL').should('have.text', '13')
|
|
})
|
|
})
|
|
})
|
|
|
|
it("17 | Insert Row below keeps existing rows' formulas aligned with their own shifted data", () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
// Row index 3 (0-indexed): PRIMARY_KEY_FIELD=4, A_COL=4, B_COL=10 ->
|
|
// HARDFORMULA = 40, SOFTFORMULA = 14.
|
|
getCellByHeaderAndRow(3, 'PRIMARY_KEY_FIELD').should('have.text', '4')
|
|
|
|
// 'Insert Row below' on row index 2 (PK=3) inserts the new blank
|
|
// row at index 3, pushing the old index-3 row (PK=4) to index 4.
|
|
insertRowViaContextMenu(2, 'Insert Row below')
|
|
|
|
// The new blank row lands at index 3 - seeded the same way as
|
|
// above, evaluating to 0 while A_COL/B_COL are still empty.
|
|
getCellByHeaderAndRow(3, 'FORMULA_HARD_COL').should('have.text', '0')
|
|
getCellByHeaderAndRow(3, 'FORMULA_SOFT_COL').should('have.text', '0')
|
|
|
|
getCellByHeaderAndRow(4, 'PRIMARY_KEY_FIELD').should('have.text', '4')
|
|
getCellByHeaderAndRow(4, 'FORMULA_HARD_COL').should('have.text', '40')
|
|
getCellByHeaderAndRow(4, 'FORMULA_SOFT_COL').should('have.text', '14')
|
|
})
|
|
})
|
|
})
|
|
|
|
it('18 | Insert Row seeds formulas that live-recalculate once A_COL/B_COL are filled in', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
insertRowViaContextMenu(0, 'Insert Row below')
|
|
|
|
// New row at index 1 - both formula columns evaluate the seeded
|
|
// formula (=C2*D2 / =C2+D2 in spreadsheet notation) against
|
|
// still-empty A_COL/B_COL, so HyperFormula treats them as 0.
|
|
getCellByHeaderAndRow(1, 'FORMULA_HARD_COL').should('have.text', '0')
|
|
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').should('have.text', '0')
|
|
|
|
getCellByHeaderAndRow(1, 'A_COL')
|
|
.dblclick({ force: true })
|
|
.then(() => {
|
|
cy.focused().type('5{enter}')
|
|
})
|
|
|
|
getCellByHeaderAndRow(1, 'B_COL')
|
|
.dblclick({ force: true })
|
|
.then(() => {
|
|
cy.focused().type('3{enter}')
|
|
})
|
|
|
|
// Both formula columns must recompute live from THIS row's own
|
|
// A_COL/B_COL - proving the seeded formula is genuinely wired to
|
|
// HyperFormula, not a one-time static snapshot.
|
|
getCellByHeaderAndRow(1, 'FORMULA_HARD_COL').should('have.text', '15')
|
|
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').should('have.text', '8')
|
|
})
|
|
})
|
|
})
|
|
|
|
it('19 | Row header shows blank for unchanged, ± for a modified row', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
getRowHeaderSymbol(0).should('have.text', ' ')
|
|
getRowHeaderCell(0)
|
|
.should('not.have.class', 'rowStatusModified')
|
|
.and('not.have.class', 'rowStatusAdded')
|
|
.and('not.have.class', 'rowStatusDeleted')
|
|
|
|
getCellByHeaderAndRow(0, 'A_COL')
|
|
.dblclick({ force: true })
|
|
.then(() => {
|
|
cy.focused().clear().type('999{enter}')
|
|
})
|
|
|
|
getRowHeaderSymbol(0).should('have.text', '±')
|
|
getRowHeaderCell(0).should('have.class', 'rowStatusModified')
|
|
})
|
|
})
|
|
})
|
|
|
|
it('20 | Row header shows - for a delete-marked row and + for a newly inserted row', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
getCellByHeaderAndRow(2, 'Delete?').then(($cell) => {
|
|
setDeleteFlag($cell[0], 'Yes', () => {
|
|
getRowHeaderSymbol(2).should('have.text', '-')
|
|
getRowHeaderCell(2).should('have.class', 'rowStatusDeleted')
|
|
|
|
insertRowViaContextMenu(0, 'Insert Row below')
|
|
|
|
// New row lands at index 1, shifting the delete-marked row
|
|
// (was index 2) down to index 3 - its own status is unaffected
|
|
// by the shift, only its position.
|
|
getRowHeaderSymbol(1).should('have.text', '+')
|
|
getRowHeaderCell(1).should('have.class', 'rowStatusAdded')
|
|
getRowHeaderSymbol(3).should('have.text', '-')
|
|
getRowHeaderCell(3).should('have.class', 'rowStatusDeleted')
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
it('21 | Inserting a row does not falsely mark unrelated rows as modified or revert an existing override', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
getCellByHeaderAndRow(0, 'FORMULA_SOFT_COL')
|
|
.dblclick({ force: true })
|
|
.then(() => {
|
|
cy.focused().clear().type('999{enter}')
|
|
})
|
|
getRowHeaderSymbol(0).should('have.text', '±')
|
|
|
|
// Untouched row, well away from the insert point below.
|
|
getRowHeaderSymbol(5).should('have.text', ' ')
|
|
|
|
insertRowViaContextMenu(2, 'Insert Row below')
|
|
|
|
// The override and its '±' mark must survive the insert...
|
|
getCellByHeaderAndRow(0, 'FORMULA_SOFT_COL').should('have.text', '999')
|
|
getRowHeaderSymbol(0).should('have.text', '±')
|
|
|
|
// ...and a row that was never touched must stay unmarked, even
|
|
// though its position shifted (row 5 is now row 6).
|
|
getRowHeaderSymbol(6).should('have.text', ' ')
|
|
})
|
|
})
|
|
})
|
|
|
|
it("22 | DC.ROW_STATUS resolves to a live cell reference, reacting to this row's own edit status", () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
// dc.row_status itself is a purely client-synthesized column (see
|
|
// editStatusColumnRule.ts) - it must never render as a visible
|
|
// header, only exist as something DC.ROW_STATUS can point a cell
|
|
// reference at.
|
|
cy.get('.ht_clone_top .htCore thead tr th').should(($ths) => {
|
|
const texts = [...$ths].map((th) => th.innerText.trim())
|
|
expect(texts).not.to.include('dc.row_status')
|
|
})
|
|
|
|
getCellByHeaderAndRow(0, 'ROW_STATUS_COL').should('have.text', 'U')
|
|
|
|
getCellByHeaderAndRow(0, 'A_COL')
|
|
.dblclick({ force: true })
|
|
.then(() => {
|
|
cy.focused().clear().type('999{enter}')
|
|
})
|
|
|
|
getCellByHeaderAndRow(0, 'ROW_STATUS_COL').should('have.text', 'M')
|
|
})
|
|
})
|
|
})
|
|
|
|
it('23 | DC.USER_NAME resolves to the current logged-in user', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
getCellByHeaderAndRow(0, 'USER_NAME_COL').should('have.text', 'sasdemo')
|
|
})
|
|
})
|
|
})
|
|
|
|
it("24 | DC.ORIG_VALUE echoes this row's pre-edit value, and is blank for a newly-inserted row", () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
getCellByHeaderAndRow(0, 'ORIG_VALUE_COL').should('have.text', 'orig-1')
|
|
|
|
insertRowViaContextMenu(0, 'Insert Row below')
|
|
|
|
// No dataSourceUnchanged match exists for a brand-new row, so
|
|
// DC.ORIG_VALUE falls back to blank rather than echoing anything.
|
|
getCellByHeaderAndRow(1, 'ORIG_VALUE_COL').should('have.text', '')
|
|
})
|
|
})
|
|
})
|
|
|
|
it('25 | Row header symbols stay aligned with their own row after sorting by another column', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
// Mark row 2 (PRIMARY_KEY_FIELD=3) for delete before sorting, so
|
|
// there's a distinctive symbol ('-') to track across the reorder.
|
|
getCellByHeaderAndRow(2, 'Delete?').then(($cell) => {
|
|
setDeleteFlag($cell[0], 'Yes', () => {
|
|
getRowHeaderSymbol(2).should('have.text', '-')
|
|
|
|
sortByColumn('FORMULA_SOFT_COL')
|
|
|
|
// Wherever PRIMARY_KEY_FIELD=3 lands visually after sorting,
|
|
// its OWN row header must show '-' - not whatever symbol
|
|
// previously belonged to that visual position.
|
|
cy.get('.ht_clone_top .htCore thead tr th')
|
|
.should(($ths) => {
|
|
const texts = [...$ths].map((th) => th.innerText.trim())
|
|
expect(texts).to.include('PRIMARY_KEY_FIELD')
|
|
})
|
|
.then(($ths) => {
|
|
const pkColIndex = [...$ths].findIndex(
|
|
(th) => th.innerText.trim() === 'PRIMARY_KEY_FIELD'
|
|
)
|
|
|
|
cy.get('.ht_master tbody tr').then((rows: any) => {
|
|
const sortedRowIndex = [...rows].findIndex(
|
|
(row: any) =>
|
|
row.childNodes[pkColIndex].innerText.trim() === '3'
|
|
)
|
|
|
|
getRowHeaderSymbol(sortedRowIndex).should('have.text', '-')
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
// CHANGE_SUMMARY_COL combines all three DC.* variables in one formula:
|
|
// IF( DC.ROW_STATUS ="U","unedited", DC.USER_NAME &" changed from "& DC.ORIG_VALUE ).
|
|
// DC.USER_NAME/DC.ORIG_VALUE are frozen literals baked in at load time,
|
|
// while DC.ROW_STATUS is a live cell reference - so editing the row
|
|
// doesn't recompute the "changed from" text, it just flips which
|
|
// already-computed branch IF() reveals.
|
|
it("26 | Combining DC.ROW_STATUS/DC.USER_NAME/DC.ORIG_VALUE in one formula reveals the frozen 'changed from' text once the row is edited", () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
getCellByHeaderAndRow(0, 'CHANGE_SUMMARY_COL').should(
|
|
'have.text',
|
|
'unedited'
|
|
)
|
|
|
|
getCellByHeaderAndRow(0, 'A_COL')
|
|
.dblclick({ force: true })
|
|
.then(() => {
|
|
cy.focused().clear().type('999{enter}')
|
|
})
|
|
|
|
getCellByHeaderAndRow(0, 'CHANGE_SUMMARY_COL').should(
|
|
'have.text',
|
|
'sasdemo changed from orig-1'
|
|
)
|
|
})
|
|
})
|
|
})
|
|
|
|
// dataSource keeps the raw '=...' formula string for a SOFTFORMULA cell -
|
|
// HyperFormula recalculates the displayed value live without ever
|
|
// mutating that underlying string, so the submit payload must be built
|
|
// from the live computed value, not from dataSource directly. Editing
|
|
// A_COL (not FORMULA_SOFT_COL itself) both marks the row as submittable
|
|
// and proves the live-recalculated value - not a stale one - is what
|
|
// gets sent.
|
|
it('27 | Submits the computed formula value, not the raw formula string', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
cy.intercept('POST', '**/SASjsApi/stp/execute*', (req) => {
|
|
const bodyText = JSON.stringify(req.body || '')
|
|
|
|
if (bodyText.includes('stagedata')) {
|
|
expect(bodyText).to.include('"FORMULA_SOFT_COL":15')
|
|
expect(bodyText).not.to.match(/"FORMULA_SOFT_COL":"?=/)
|
|
}
|
|
|
|
req.continue()
|
|
}).as('stpExecute')
|
|
|
|
// Row 0: PRIMARY_KEY_FIELD=1, B_COL=10. Changing A_COL from 1 to 5
|
|
// marks the row modified (so it's actually submitted) and live-
|
|
// recalculates FORMULA_SOFT_COL (A_COL + B_COL) from 11 to 15,
|
|
// without the user ever directly editing that cell.
|
|
getCellByHeaderAndRow(0, 'A_COL')
|
|
.dblclick({ force: true })
|
|
.then(() => {
|
|
cy.focused().clear().type('5{enter}')
|
|
})
|
|
|
|
getCellByHeaderAndRow(0, 'FORMULA_SOFT_COL').should('have.text', '15')
|
|
|
|
submitTable()
|
|
|
|
cy.get('#submitBtn', { timeout: longerCommandTimeout })
|
|
.should('exist')
|
|
.should('not.be.disabled')
|
|
|
|
cy.get('#formFields_8').type('formula value submission test')
|
|
|
|
submitTableMessage()
|
|
})
|
|
})
|
|
})
|
|
|
|
// Row 9 (0-indexed, PRIMARY_KEY_FIELD=10): the mock seeds
|
|
// FORMULA_HARD_COL/FORMULA_SOFT_COL with real pre-existing values
|
|
// (1111/2222) that the computed formula (A_COL*B_COL=100,
|
|
// A_COL+B_COL=20 for this row) silently overwrites at load - simulating
|
|
// a formula rule added to a column that already had real data. Every
|
|
// other row's formula columns are seeded blank, so only this row is
|
|
// affected.
|
|
it('28 | A formula overwriting real pre-existing data marks the row modified, adds a comment, and can be reverted', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
|
|
|
// Already true on the very first render, before Edit is ever clicked -
|
|
// dataSourceUnchanged is seeded with the formula overlay at load time
|
|
// too, not just inside editTable().
|
|
getRowHeaderSymbol(9).should('have.text', '±')
|
|
getCellByHeaderAndRow(9, 'ROW_STATUS_COL').should('have.text', 'M')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
getRowHeaderSymbol(9).should('have.text', '±')
|
|
|
|
// The silent overwrite is a real, permanent difference from the raw
|
|
// dataset (not a session edit afterChange would ever see), so
|
|
// DC.ROW_STATUS must already read 'M' here too, not just the row
|
|
// header symbol.
|
|
getCellByHeaderAndRow(9, 'ROW_STATUS_COL').should('have.text', 'M')
|
|
|
|
getCellByHeaderAndRow(9, 'FORMULA_HARD_COL')
|
|
.should('have.text', '100')
|
|
.and('have.class', 'htCommentCell')
|
|
|
|
getCellByHeaderAndRow(9, 'FORMULA_SOFT_COL')
|
|
.should('have.text', '20')
|
|
.and('have.class', 'htCommentCell')
|
|
.rightclick({ force: true })
|
|
|
|
// Only our own "Revert" is offered - never the Comments plugin's
|
|
// own add/edit/delete items, since comments here are strictly
|
|
// programmatic (see the comments: {readOnly: true} setting and the
|
|
// deliberately curated contextMenu.items list).
|
|
cy.get('.htContextMenu').should(($menu) => {
|
|
const text = $menu.text()
|
|
expect(text).to.include('Revert')
|
|
expect(text).not.to.include('Add comment')
|
|
expect(text).not.to.include('Edit comment')
|
|
expect(text).not.to.include('Delete comment')
|
|
})
|
|
|
|
cy.get('.htContextMenu').contains('Revert').click()
|
|
|
|
getCellByHeaderAndRow(9, 'FORMULA_SOFT_COL')
|
|
.should('have.text', '2222')
|
|
.and('not.have.class', 'htCommentCell')
|
|
})
|
|
})
|
|
})
|
|
|
|
// dataSourceUnchanged deliberately holds the RAW pre-formula value for
|
|
// FORMULA_HARD_COL/FORMULA_SOFT_COL so classifyRow flags row 9 as
|
|
// modified (see test 28) - but that's not a session edit for Cancel to
|
|
// discard: the formula recomputes on every load regardless of what's
|
|
// sitting in dataSourceUnchanged. Cancelling (without ever touching
|
|
// "Revert") must keep showing the live computed value and the modified
|
|
// marker, both in the cancelled edit session and back in read-only view.
|
|
it('29 | Cancelling an edit session after a formula silently overwrote real data keeps the computed value and modified marker, not the stale raw one', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
getCellByHeaderAndRow(9, 'FORMULA_HARD_COL').should('have.text', '100')
|
|
getCellByHeaderAndRow(9, 'FORMULA_SOFT_COL').should('have.text', '20')
|
|
|
|
cy.contains('button', 'Cancel').click()
|
|
|
|
getRowHeaderSymbol(9).should('have.text', '±')
|
|
getCellByHeaderAndRow(9, 'ROW_STATUS_COL').should('have.text', 'M')
|
|
getCellByHeaderAndRow(9, 'FORMULA_HARD_COL').should('have.text', '100')
|
|
getCellByHeaderAndRow(9, 'FORMULA_SOFT_COL').should('have.text', '20')
|
|
})
|
|
})
|
|
})
|
|
|
|
// CHANGE_SUMMARY_COL's own raw seed ('orig-1') genuinely differs from its
|
|
// computed result ('unedited') for row 0, purely because its formula
|
|
// references DC.ROW_STATUS/DC.ORIG_VALUE, which are inherently
|
|
// session-dependent - not because anything overwrote real data. See
|
|
// getStableFormulaBaseCols, which excludes any DC.*-referencing formula
|
|
// rule from the "did a formula overwrite real data" comparison entirely.
|
|
it('30 | DC.*-referencing formula columns never get a "changed value" comment, even when their own raw seed differs from the computed result', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
getCellByHeaderAndRow(0, 'ORIG_VALUE_COL')
|
|
.should('have.text', 'orig-1')
|
|
.and('not.have.class', 'htCommentCell')
|
|
|
|
getCellByHeaderAndRow(0, 'CHANGE_SUMMARY_COL')
|
|
.should('have.text', 'unedited')
|
|
.and('not.have.class', 'htCommentCell')
|
|
})
|
|
})
|
|
})
|
|
|
|
it('31 | Reverting a formula value then cancelling keeps it plain, and it stays plain on the next edit session', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
getCellByHeaderAndRow(9, 'FORMULA_SOFT_COL').rightclick({
|
|
force: true
|
|
})
|
|
cy.get('.htContextMenu').contains('Revert').click()
|
|
|
|
getCellByHeaderAndRow(9, 'FORMULA_SOFT_COL')
|
|
.should('have.text', '2222')
|
|
.and('not.have.class', 'htCommentCell')
|
|
|
|
cy.contains('button', 'Cancel').click()
|
|
})
|
|
})
|
|
|
|
// Back in view mode: the revert is a real, permanent change - not a
|
|
// session edit for Cancel to discard (see
|
|
// getFormulaCellsToPreserveOnCancel - a reverted cell has no comment,
|
|
// so it's excluded from the preserve-the-computed-value patch, and
|
|
// dataSourceUnchanged's raw value, which now matches, is left as-is).
|
|
// FORMULA_HARD_COL was never reverted, so it still shows the live
|
|
// computed value and keeps the row modified via that column alone.
|
|
getCellByHeaderAndRow(9, 'FORMULA_SOFT_COL')
|
|
.should('have.text', '2222')
|
|
.and('not.have.class', 'htCommentCell')
|
|
getCellByHeaderAndRow(9, 'FORMULA_HARD_COL').should('have.text', '100')
|
|
getRowHeaderSymbol(9).should('have.text', '±')
|
|
|
|
// "Forever" per the original requirement - re-entering edit mode must
|
|
// not resurrect the formula for the reverted cell.
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
getCellByHeaderAndRow(9, 'FORMULA_SOFT_COL')
|
|
.should('have.text', '2222')
|
|
.and('not.have.class', 'htCommentCell')
|
|
})
|
|
})
|
|
})
|
|
|
|
// Generalized revert: any cell whose value differs from what SAS
|
|
// actually sent, not only formula columns - A_COL is a plain numeric
|
|
// column with no HARDFORMULA/SOFTFORMULA rule of its own.
|
|
it('32 | Editing a plain (non-formula) cell marks it overwritten, and Revert restores it', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
getCellByHeaderAndRow(1, 'A_COL')
|
|
.should('have.text', '2')
|
|
.and('not.have.class', 'htCommentCell')
|
|
.dblclick({ force: true })
|
|
.then(() => {
|
|
cy.focused().clear().type('999{enter}')
|
|
})
|
|
|
|
getCellByHeaderAndRow(1, 'A_COL')
|
|
.should('have.text', '999')
|
|
.and('have.class', 'htCommentCell')
|
|
.rightclick({ force: true })
|
|
|
|
cy.get('.htContextMenu').contains('Revert').click()
|
|
|
|
getCellByHeaderAndRow(1, 'A_COL')
|
|
.should('have.text', '2')
|
|
.and('not.have.class', 'htCommentCell')
|
|
})
|
|
})
|
|
})
|
|
|
|
it('33 | Selecting a range containing one overwritten cell shows Revert and only reverts that cell', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
getCellByHeaderAndRow(2, 'A_COL')
|
|
.dblclick({ force: true })
|
|
.then(() => {
|
|
cy.focused().clear().type('999{enter}')
|
|
})
|
|
|
|
getCellByHeaderAndRow(2, 'A_COL').should('have.class', 'htCommentCell')
|
|
|
|
// Select the whole row's cell range (PRIMARY_KEY_FIELD through
|
|
// CHANGE_SUMMARY_COL) via shift-click, covering both the
|
|
// overwritten A_COL and several untouched cells.
|
|
getCellByHeaderAndRow(2, 'PRIMARY_KEY_FIELD').click({ force: true })
|
|
getCellByHeaderAndRow(2, 'CHANGE_SUMMARY_COL').click({
|
|
force: true,
|
|
shiftKey: true
|
|
})
|
|
getCellByHeaderAndRow(2, 'B_COL').rightclick({ force: true })
|
|
|
|
cy.get('.htContextMenu').contains('Revert').click()
|
|
|
|
getCellByHeaderAndRow(2, 'A_COL')
|
|
.should('have.text', '3')
|
|
.and('not.have.class', 'htCommentCell')
|
|
// An untouched cell within the same reverted selection is left
|
|
// exactly as it was - proves Revert only acts on the cell(s) that
|
|
// were actually overwritten, not the whole selection.
|
|
getCellByHeaderAndRow(2, 'B_COL').should('have.text', '10')
|
|
})
|
|
})
|
|
})
|
|
|
|
it('34 | Selecting a range with no overwritten cells does not show Revert', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
getCellByHeaderAndRow(3, 'PRIMARY_KEY_FIELD').click({ force: true })
|
|
getCellByHeaderAndRow(3, 'CHANGE_SUMMARY_COL').click({
|
|
force: true,
|
|
shiftKey: true
|
|
})
|
|
getCellByHeaderAndRow(3, 'B_COL').rightclick({ force: true })
|
|
|
|
cy.get('.htContextMenu').should(($menu) => {
|
|
expect($menu.text()).not.to.include('Revert')
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
it('35 | A selection spanning a newly-inserted row and an existing overwritten cell only reverts the existing row', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
getCellByHeaderAndRow(0, 'A_COL')
|
|
.dblclick({ force: true })
|
|
.then(() => {
|
|
cy.focused().clear().type('999{enter}')
|
|
})
|
|
|
|
getCellByHeaderAndRow(0, 'A_COL').should('have.class', 'htCommentCell')
|
|
|
|
insertRowViaContextMenu(0, 'Insert Row below')
|
|
|
|
// New row lands at index 1 - select a range spanning both the
|
|
// existing overwritten row (0) and the brand-new row (1).
|
|
getCellByHeaderAndRow(0, 'A_COL').click({ force: true })
|
|
getCellByHeaderAndRow(1, 'A_COL').click({
|
|
force: true,
|
|
shiftKey: true
|
|
})
|
|
getCellByHeaderAndRow(0, 'A_COL').rightclick({ force: true })
|
|
|
|
cy.get('.htContextMenu').contains('Revert').click()
|
|
|
|
getCellByHeaderAndRow(0, 'A_COL')
|
|
.should('have.text', '1')
|
|
.and('not.have.class', 'htCommentCell')
|
|
// The new row has no original SAS value to revert to - it's simply
|
|
// left alone, no error.
|
|
getCellByHeaderAndRow(1, 'A_COL').should('have.text', '')
|
|
})
|
|
})
|
|
})
|
|
|
|
// Rows 7-9 (0-indexed 6-8) are seeded with a staggered pattern (see the
|
|
// mock's own comment): row 7 only overwrites FORMULA_HARD_COL, row 8
|
|
// only overwrites FORMULA_SOFT_COL, row 9 overwrites both - all three
|
|
// already show their raw-vs-computed mismatch on load, with no manual
|
|
// edit needed.
|
|
it('36 | Selecting a block spanning multiple pre-loaded overwritten cells across different rows and columns reverts only the actually-overwritten ones', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
getCellByHeaderAndRow(6, 'FORMULA_HARD_COL')
|
|
.should('have.text', '70')
|
|
.and('have.class', 'htCommentCell')
|
|
getCellByHeaderAndRow(7, 'FORMULA_SOFT_COL')
|
|
.should('have.text', '18')
|
|
.and('have.class', 'htCommentCell')
|
|
getCellByHeaderAndRow(8, 'FORMULA_HARD_COL')
|
|
.should('have.text', '90')
|
|
.and('have.class', 'htCommentCell')
|
|
getCellByHeaderAndRow(8, 'FORMULA_SOFT_COL')
|
|
.should('have.text', '19')
|
|
.and('have.class', 'htCommentCell')
|
|
|
|
// Select the 3-row x 2-col block covering rows 6-8, columns
|
|
// FORMULA_HARD_COL through FORMULA_SOFT_COL.
|
|
getCellByHeaderAndRow(6, 'FORMULA_HARD_COL').click({ force: true })
|
|
getCellByHeaderAndRow(8, 'FORMULA_SOFT_COL').click({
|
|
force: true,
|
|
shiftKey: true
|
|
})
|
|
getCellByHeaderAndRow(7, 'FORMULA_HARD_COL').rightclick({
|
|
force: true
|
|
})
|
|
|
|
cy.get('.htContextMenu').contains('Revert').click()
|
|
|
|
getCellByHeaderAndRow(6, 'FORMULA_HARD_COL')
|
|
.should('have.text', '7771')
|
|
.and('not.have.class', 'htCommentCell')
|
|
getCellByHeaderAndRow(7, 'FORMULA_SOFT_COL')
|
|
.should('have.text', '8882')
|
|
.and('not.have.class', 'htCommentCell')
|
|
getCellByHeaderAndRow(8, 'FORMULA_HARD_COL')
|
|
.should('have.text', '9991')
|
|
.and('not.have.class', 'htCommentCell')
|
|
getCellByHeaderAndRow(8, 'FORMULA_SOFT_COL')
|
|
.should('have.text', '9992')
|
|
.and('not.have.class', 'htCommentCell')
|
|
|
|
// Cells within the same block that were never overwritten in the
|
|
// first place are left exactly as they were computed.
|
|
getCellByHeaderAndRow(6, 'FORMULA_SOFT_COL')
|
|
.should('have.text', '17')
|
|
.and('not.have.class', 'htCommentCell')
|
|
getCellByHeaderAndRow(7, 'FORMULA_HARD_COL')
|
|
.should('have.text', '80')
|
|
.and('not.have.class', 'htCommentCell')
|
|
})
|
|
})
|
|
})
|
|
|
|
it('37 | Selecting an entire column reverts every pre-loaded overwritten cell in it, leaving other columns and out-of-range rows untouched', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
// Select every row of FORMULA_HARD_COL only - rows 6, 8 and 9 are
|
|
// overwritten in it, rows 0-5 and 7 are not.
|
|
getCellByHeaderAndRow(0, 'FORMULA_HARD_COL').click({ force: true })
|
|
getCellByHeaderAndRow(9, 'FORMULA_HARD_COL').click({
|
|
force: true,
|
|
shiftKey: true
|
|
})
|
|
getCellByHeaderAndRow(3, 'FORMULA_HARD_COL').rightclick({
|
|
force: true
|
|
})
|
|
|
|
cy.get('.htContextMenu').contains('Revert').click()
|
|
|
|
getCellByHeaderAndRow(6, 'FORMULA_HARD_COL')
|
|
.should('have.text', '7771')
|
|
.and('not.have.class', 'htCommentCell')
|
|
getCellByHeaderAndRow(8, 'FORMULA_HARD_COL')
|
|
.should('have.text', '9991')
|
|
.and('not.have.class', 'htCommentCell')
|
|
getCellByHeaderAndRow(9, 'FORMULA_HARD_COL')
|
|
.should('have.text', '1111')
|
|
.and('not.have.class', 'htCommentCell')
|
|
|
|
// A row that was never overwritten in FORMULA_HARD_COL is left as
|
|
// its computed value.
|
|
getCellByHeaderAndRow(2, 'FORMULA_HARD_COL')
|
|
.should('have.text', '30')
|
|
.and('not.have.class', 'htCommentCell')
|
|
|
|
// FORMULA_SOFT_COL was never part of the selection - row 7's
|
|
// pre-loaded overwrite there must survive untouched. It's a live
|
|
// formula cell, so it still displays the computed value (18), not
|
|
// the raw seed (8882) - the comment is what carries the raw value,
|
|
// not the display text (see test 36's identical assertion).
|
|
getCellByHeaderAndRow(7, 'FORMULA_SOFT_COL')
|
|
.should('have.text', '18')
|
|
.and('have.class', 'htCommentCell')
|
|
})
|
|
})
|
|
})
|
|
|
|
// PLAIN_TEXT_COL has no DQ rule at all (not HARDFORMULA/SOFTFORMULA, not
|
|
// even NOTNULL) - unlike FORMULA_HARD_COL/FORMULA_SOFT_COL it can never be
|
|
// pre-loaded as already overwritten (nothing mutates a value between the
|
|
// dataSourceRaw snapshot and first render except a formula rule), so this
|
|
// proves the general revert path still works via a plain live edit on a
|
|
// column that's a string, not a number.
|
|
it('38 | Editing a plain character column with no DQ rule at all marks it overwritten, and Revert restores it', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
getCellByHeaderAndRow(1, 'PLAIN_TEXT_COL')
|
|
.should('have.text', 'note-2')
|
|
.and('not.have.class', 'htCommentCell')
|
|
.dblclick({ force: true })
|
|
.then(() => {
|
|
cy.focused().clear().type('edited by user{enter}')
|
|
})
|
|
|
|
getCellByHeaderAndRow(1, 'PLAIN_TEXT_COL')
|
|
.should('have.text', 'edited by user')
|
|
.and('have.class', 'htCommentCell')
|
|
.rightclick({ force: true })
|
|
|
|
cy.get('.htContextMenu').contains('Revert').click()
|
|
|
|
getCellByHeaderAndRow(1, 'PLAIN_TEXT_COL')
|
|
.should('have.text', 'note-2')
|
|
.and('not.have.class', 'htCommentCell')
|
|
})
|
|
})
|
|
})
|
|
|
|
// The info item has no callback (it's read-only display text), but
|
|
// Handsontable's Menu widget still treated any click landing inside it as
|
|
// "the item was activated" - closing the menu on left-click and, on
|
|
// right-click, preventDefault()-ing before the browser's own "Copy" menu
|
|
// could appear. Either way the user could never select/copy the text. If
|
|
// the dropdown were still closing, `.htDropdownMenu` would no longer
|
|
// exist and the `.should()` below would time out.
|
|
it('39 | Left-click or right-click inside the info dropdown does not close it', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
openColumnDropdown('FORMULA_HARD_COL')
|
|
|
|
cy.get('.htDropdownMenu').should(($menu) => {
|
|
expect($menu.text()).to.include('NAME: FORMULA_HARD_COL')
|
|
})
|
|
|
|
cy.get('.htDropdownMenu').contains('NAME:').click()
|
|
|
|
cy.get('.htDropdownMenu').should(($menu) => {
|
|
expect($menu.text()).to.include('NAME: FORMULA_HARD_COL')
|
|
})
|
|
|
|
cy.get('.htDropdownMenu').contains('NAME:').rightclick()
|
|
|
|
cy.get('.htDropdownMenu').should(($menu) => {
|
|
expect($menu.text()).to.include('NAME: FORMULA_HARD_COL')
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
// Row 1 (0-indexed): A_COL=2, B_COL=10 - see the HARDFORMULA/SOFTFORMULA
|
|
// tests' own comment above. FORMULA_SOFT_COL is a character column (see
|
|
// character-column-formula-plan.md), so it's formula-capable for pasted
|
|
// input; unlike FORMULA_HARD_COL it also accepts a user override (test 10).
|
|
it("40 | Pasting a formula with column names translates them to this row's cell references and evaluates it, on a formula-designated column", () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').click()
|
|
|
|
pasteTextIntoFocusedCell('=A_COL * B_COL')
|
|
|
|
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').should('have.text', '20')
|
|
})
|
|
})
|
|
})
|
|
|
|
// mpe_x_test has zero HARDFORMULA/SOFTFORMULA rules, but formulas are on
|
|
// for every table, not just ones that declare a formula rule - so
|
|
// paste-translation still works here too. Row 1 (0-indexed): SOME_BESTNUM
|
|
// = (1 % 90) + 10 = 11, so 11 * 11 = 121.
|
|
it('41 | Pasting a formula with column names on a table with zero formula rules still translates and evaluates it', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
// force: true - same overlay-clone-layer reason as sortByColumn's
|
|
// own comment below; SOME_CHAR's header sort icon covers the cell.
|
|
getCellByHeaderAndRow(1, 'SOME_CHAR').click({ force: true })
|
|
|
|
pasteTextIntoFocusedCell('=SOME_BESTNUM * SOME_BESTNUM')
|
|
|
|
getCellByHeaderAndRow(1, 'SOME_CHAR').should('have.text', '121')
|
|
})
|
|
})
|
|
})
|
|
|
|
it('42 | Pasting a formula referencing a column name inside a quoted string leaves the quoted text untouched', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').click()
|
|
|
|
// A_COL needs a leading/trailing blank to be recognised as a
|
|
// column-name token (see substituteColumnReferences' own "no
|
|
// surrounding blanks" test) - matches the space-after-paren
|
|
// convention already used by this fixture's own CHANGE_SUMMARY_COL
|
|
// rule value ('=IF( DC.ROW_STATUS ="U",...)').
|
|
pasteTextIntoFocusedCell(
|
|
'=IF( A_COL > 0, "A_COL is positive", "negative")'
|
|
)
|
|
|
|
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').should(
|
|
'have.text',
|
|
'A_COL is positive'
|
|
)
|
|
})
|
|
})
|
|
})
|
|
|
|
it('43 | Pasting a formula directly into an open cell editor (double-click, then paste) also translates and evaluates it', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL')
|
|
.dblclick({ force: true })
|
|
.then(() => {
|
|
pasteTextIntoFocusedCell('=A_COL * B_COL')
|
|
|
|
cy.focused().type('{enter}')
|
|
|
|
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').should(
|
|
'have.text',
|
|
'20'
|
|
)
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
// PLAIN_TEXT_COL has no DQ rule at all - not HARDFORMULA/SOFTFORMULA-
|
|
// designated, and not referenced by any formula either. Only
|
|
// backend-sourced `=`-led values are ever auto-escaped - anything the
|
|
// user pastes or types is left exactly as entered, which genuinely
|
|
// evaluates as a live formula since formulas are on for every column.
|
|
it('44 | Pasting a formula-looking value into any character column evaluates it as a real formula - user input is never auto-escaped', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
getCellByHeaderAndRow(1, 'PLAIN_TEXT_COL').click()
|
|
|
|
pasteTextIntoFocusedCell('=100 * 100')
|
|
|
|
getCellByHeaderAndRow(1, 'PLAIN_TEXT_COL').should('have.text', '10000')
|
|
})
|
|
})
|
|
})
|
|
|
|
it('44b | Typing a formula-looking value directly into a character column evaluates it as a real formula too', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
getCellByHeaderAndRow(2, 'PLAIN_TEXT_COL')
|
|
.dblclick({ force: true })
|
|
.then(() => {
|
|
cy.focused().clear().type('=100 * 100{enter}')
|
|
})
|
|
|
|
getCellByHeaderAndRow(2, 'PLAIN_TEXT_COL').should('have.text', '10000')
|
|
})
|
|
})
|
|
})
|
|
|
|
// Rows 5/6/7 (0-indexed 4/5/6) come straight from the mock backend, never
|
|
// touched by a user - see getdata.js's own comment. Rows 5-6 are a
|
|
// `=`-led value that must be auto-escaped to literal text on load (not
|
|
// evaluated - only a *user's* input evaluates live, per test 44 above);
|
|
// row 7 is already apostrophe-escaped straight from the backend, which
|
|
// the app must never mistake for its own marker.
|
|
it('46 | A formula-looking value seeded from the backend into a character column is escaped to literal text on load; a genuinely-literal backend value is left untouched', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
// Row 5 (PK=5): references a column name (B_COL) - never
|
|
// translated (that only happens for a pasted value), so this is
|
|
// purely proving the escape, not evaluation.
|
|
getCellByHeaderAndRow(4, 'PLAIN_TEXT_COL').should(
|
|
'have.text',
|
|
'=100 + B_COL'
|
|
)
|
|
|
|
// Row 6 (PK=6): a bare arithmetic expression.
|
|
getCellByHeaderAndRow(5, 'PLAIN_TEXT_COL').should(
|
|
'have.text',
|
|
'=100 + 200'
|
|
)
|
|
|
|
// Row 7 (PK=7): already apostrophe-escaped in the backend data
|
|
// itself - Handsontable's own formulas-plugin display logic strips
|
|
// exactly one leading `'` for display regardless of who put it
|
|
// there, so this also reads as literal text with no leading `'`
|
|
// visible - the difference from rows 5-6 only shows up in what
|
|
// gets submitted (test 45) and in "Apply as formula" (test 47).
|
|
getCellByHeaderAndRow(6, 'PLAIN_TEXT_COL').should(
|
|
'have.text',
|
|
'=genuine literal'
|
|
)
|
|
})
|
|
})
|
|
})
|
|
|
|
// Proves requirements 2/5/6 together: an untouched auto-escaped cell
|
|
// (row 5) submits with the marker stripped back off; a genuinely-literal
|
|
// backend value (row 7) - never marked, since it never started with `=`
|
|
// in the first place - submits completely unchanged, leading `'` and all.
|
|
it('45 | Submits an untouched auto-escaped cell unmarked, and a genuinely-literal backend value completely unchanged', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
cy.intercept('POST', '**/SASjsApi/stp/execute*', (req) => {
|
|
const bodyText = JSON.stringify(req.body || '')
|
|
|
|
if (bodyText.includes('stagedata')) {
|
|
expect(bodyText).to.include('"PLAIN_TEXT_COL":"=100 + 200"')
|
|
expect(bodyText).not.to.include("'=100 + 200")
|
|
expect(bodyText).to.include('"PLAIN_TEXT_COL":"\'=genuine literal"')
|
|
}
|
|
|
|
req.continue()
|
|
}).as('stpExecute')
|
|
|
|
// PLAIN_TEXT_COL itself is never touched by hand on either row -
|
|
// editing each row's own A_COL instead marks both rows submittable
|
|
// (row 6 = PK 6, "=100 + 200"; row 7 = PK 7, the genuine literal)
|
|
// without disturbing PLAIN_TEXT_COL's auto-escaped/untouched state.
|
|
getCellByHeaderAndRow(5, 'A_COL')
|
|
.dblclick({ force: true })
|
|
.then(() => {
|
|
cy.focused().clear().type('60{enter}')
|
|
})
|
|
getCellByHeaderAndRow(6, 'A_COL')
|
|
.dblclick({ force: true })
|
|
.then(() => {
|
|
cy.focused().clear().type('70{enter}')
|
|
})
|
|
|
|
submitTable()
|
|
|
|
cy.get('#submitBtn', { timeout: longerCommandTimeout })
|
|
.should('exist')
|
|
.should('not.be.disabled')
|
|
|
|
cy.get('#formFields_8').type('round-trip submission test')
|
|
|
|
submitTableMessage()
|
|
})
|
|
})
|
|
})
|
|
|
|
// Requirement 7: "Apply as formula" only ever appears for / acts on a
|
|
// cell the app itself auto-escaped.
|
|
it('47 | "Apply as formula" is hidden when the selection has no auto-escaped cell', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
// Row 7 (PK=7) is genuinely-literal, never auto-escaped.
|
|
getCellByHeaderAndRow(6, 'PLAIN_TEXT_COL').click({ force: true })
|
|
getCellByHeaderAndRow(6, 'PLAIN_TEXT_COL').rightclick({ force: true })
|
|
|
|
cy.get('.htContextMenu').should(($menu) => {
|
|
expect($menu.text()).not.to.include('Apply as formula')
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
it('48 | "Apply as formula" appears when the selection has an auto-escaped cell, and only strips the auto-inserted marker from it', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
|
|
|
clickOnEdit(() => {
|
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
timeout: longerCommandTimeout
|
|
}).then(() => {
|
|
// Select rows 6-7 (PK 6-7) of PLAIN_TEXT_COL - row 6 auto-escaped
|
|
// (bare expression, no column-name translation needed), row 7
|
|
// genuinely literal from the backend.
|
|
getCellByHeaderAndRow(5, 'PLAIN_TEXT_COL').click({ force: true })
|
|
getCellByHeaderAndRow(6, 'PLAIN_TEXT_COL').click({
|
|
force: true,
|
|
shiftKey: true
|
|
})
|
|
getCellByHeaderAndRow(5, 'PLAIN_TEXT_COL').rightclick({ force: true })
|
|
|
|
cy.get('.htContextMenu').contains('Apply as formula').click()
|
|
|
|
// Row 6: marker stripped, now a real live formula.
|
|
getCellByHeaderAndRow(5, 'PLAIN_TEXT_COL').should('have.text', '300')
|
|
// Row 7: never marked, left completely untouched.
|
|
getCellByHeaderAndRow(6, 'PLAIN_TEXT_COL').should(
|
|
'have.text',
|
|
'=genuine literal'
|
|
)
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
// Handsontable virtualizes columns — with 18 columns on MPE_X_NEW, only
|
|
// the ones in the current viewport actually exist in the DOM. Scroll all
|
|
// the way right so REGEX_HARD_COL/REGEX_SOFT_COL/REGEX_BOTH_COL (the last
|
|
// three) render at all. Same technique already used in excel.cy.ts. Must
|
|
// be called (and re-settle) before any header/body query below, since
|
|
// scrolling replaces the previously-rendered column nodes.
|
|
const scrollGridRight = () => {
|
|
return cy
|
|
.get('#hotTable')
|
|
.find('div.ht_master.handsontable')
|
|
.find('div.wtHolder')
|
|
.scrollTo('right')
|
|
}
|
|
|
|
// Clicks a column header's sort indicator (Handsontable's multiColumnSorting
|
|
// plugin decorates every sortable header with a `.columnSorting` element -
|
|
// HEADER_SORT_CLASS in Handsontable's own source) to sort ascending by that
|
|
// column. force: true for the same reason as openColumnDropdown - an
|
|
// overlay clone layer can sit over the real target.
|
|
const sortByColumn = (headerText: string) => {
|
|
cy.get('.ht_clone_top .htCore thead tr th')
|
|
.filter((_, th) => Cypress.$(th).text().includes(headerText))
|
|
.last()
|
|
.find('.columnSorting')
|
|
.click({ force: true })
|
|
}
|
|
|
|
// Opens a column header's dropdown menu to reach its `info` item, which
|
|
// has a custom renderer showing NAME/LABEL/TYPE/LENGTH/FORMAT and, when the
|
|
// column has a HARDREGEX/SOFTREGEX rule, the applied pattern. Clicking the
|
|
// header text first selects the column - the renderer reads
|
|
// hot.getSelected() to decide which column to describe.
|
|
const openColumnDropdown = (headerText: string) => {
|
|
cy.get('#hotTable .ht_clone_top .htCore thead button.changeType', {
|
|
timeout: longerCommandTimeout
|
|
})
|
|
.parents('th')
|
|
.filter((_, th) => Cypress.$(th).text().includes(headerText))
|
|
.last()
|
|
.as('targetHeader')
|
|
|
|
cy.get('@targetHeader').click({ force: true })
|
|
cy.get('@targetHeader').find('button.changeType').click({ force: true })
|
|
}
|
|
|
|
// Locates a body cell by its column's header text rather than a hardcoded
|
|
// childNodes index. Handsontable's hiddenColumns plugin (e.g. HIDDEN_COL,
|
|
// used by several demo columns ahead of REGEX_HARD_COL/REGEX_SOFT_COL in
|
|
// COLHEADERS) removes a hidden column's cells from BOTH the header row and
|
|
// every body row identically, so the header row's th index always lines up
|
|
// with the same-position childNode in any body row — this sidesteps having
|
|
// to know how many hidden columns precede the target. Re-queries both the
|
|
// header and body row fresh each call (rather than accepting a pre-fetched
|
|
// row) since virtualization/scrolling can replace either pane's nodes.
|
|
//
|
|
// The `.should()` retries until headerText actually shows up: Handsontable
|
|
// re-renders virtualized columns asynchronously after a scroll event, on
|
|
// its own schedule outside Cypress's command queue, so scrollTo() settling
|
|
// does not mean the target column has been rendered yet.
|
|
// Simulates a real clipboard paste into whichever element currently has
|
|
// DOM focus (Handsontable moves focus to its own internal textarea once a
|
|
// cell is selected/clicked) - Cypress has no built-in clipboard simulation,
|
|
// and Handsontable's CopyPaste plugin only listens for a native 'paste'
|
|
// DOM event with clipboardData, so a synthetic ClipboardEvent is
|
|
// dispatched directly rather than trying to drive the OS clipboard.
|
|
const pasteTextIntoFocusedCell = (text: string) => {
|
|
cy.focused().then(($el) => {
|
|
const dataTransfer = new DataTransfer()
|
|
dataTransfer.setData('text/plain', text)
|
|
|
|
const pasteEvent = new ClipboardEvent('paste', {
|
|
clipboardData: dataTransfer,
|
|
bubbles: true,
|
|
cancelable: true
|
|
})
|
|
|
|
$el[0].dispatchEvent(pasteEvent)
|
|
})
|
|
}
|
|
|
|
const getCellByHeaderAndRow = (rowIndex: number, headerText: string) => {
|
|
return cy
|
|
.get('.ht_clone_top .htCore thead tr th')
|
|
.should(($ths) => {
|
|
const texts = [...$ths].map((th) => th.innerText.trim())
|
|
expect(texts).to.include(headerText)
|
|
})
|
|
.then(($ths) => {
|
|
const index = [...$ths].findIndex(
|
|
(th) => th.innerText.trim() === headerText
|
|
)
|
|
|
|
return cy
|
|
.get('.ht_master tbody tr')
|
|
.then((rows: any) => rows[rowIndex].childNodes[index])
|
|
.then((cell) => cy.get(cell))
|
|
})
|
|
}
|
|
|
|
// Reads the row-header gutter's text for the given row - this is the
|
|
// left-hand "select entire row" bar (rowHeaders callback in
|
|
// editor.component.ts), doubling as the edit-status indicator
|
|
// (+/-/±/blank). .ht_clone_left mirrors .ht_clone_top's frozen-clone role,
|
|
// just for the row axis instead of the column axis.
|
|
const getRowHeaderSymbol = (rowIndex: number) => {
|
|
return cy
|
|
.get('.ht_clone_left .htCore tbody tr')
|
|
.eq(rowIndex)
|
|
.find('th .rowHeader')
|
|
}
|
|
|
|
// The th itself (not its inner .rowHeader span) - carries the
|
|
// rowStatusAdded/rowStatusDeleted/rowStatusModified class that colors the
|
|
// row-header background (see afterGetRowHeader in editor.component.ts).
|
|
// Checking for the class rather than an exact computed colour, since the
|
|
// actual colour comes from a Clarity design-token CSS variable and isn't
|
|
// meant to be pinned here.
|
|
const getRowHeaderCell = (rowIndex: number) => {
|
|
return cy.get('.ht_clone_left .htCore tbody tr').eq(rowIndex).find('th')
|
|
}
|
|
|
|
// Right-clicks the given row's PRIMARY_KEY_FIELD cell to open the row's
|
|
// context menu, then clicks the given item. 'Insert Row above'/'below' are
|
|
// hidden when hotTable.readOnly is true, so this only works after
|
|
// clickOnEdit(). force: true - same reasoning as toggleColumnLabelsFromContextMenu
|
|
// in viewer-labels.cy.ts, the right-clicked cell can sit under an overlay
|
|
// clone layer that fails Cypress's actionability check.
|
|
const insertRowViaContextMenu = (
|
|
rowIndex: number,
|
|
menuItemText: 'Insert Row above' | 'Insert Row below'
|
|
) => {
|
|
getCellByHeaderAndRow(rowIndex, 'PRIMARY_KEY_FIELD').rightclick({
|
|
force: true
|
|
})
|
|
cy.get('.htContextMenu').contains(menuItemText).click()
|
|
}
|
|
|
|
// Opens the _____DELETE__THIS__RECORD_____ dropdown editor on the given cell
|
|
// and picks the given choice ('Yes'/'No') — same technique as
|
|
// coltype-delete-record.cy.ts (arrow -> autocompleteEditor choice list).
|
|
const setDeleteFlag = (
|
|
deleteCell: any,
|
|
value: 'Yes' | 'No',
|
|
callback?: any
|
|
) => {
|
|
cy.get(deleteCell)
|
|
.click({ force: true })
|
|
.then(($td) => {
|
|
cy.get('.htAutocompleteArrow', { withinSubject: $td })
|
|
.should('exist')
|
|
.click({ force: true })
|
|
|
|
cy.get('.autocompleteEditor .htCore tbody td')
|
|
.contains(value)
|
|
.click({ force: true })
|
|
.then(() => {
|
|
if (callback) callback()
|
|
})
|
|
})
|
|
}
|
|
|
|
const clickOnEdit = (callback?: any) => {
|
|
cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout })
|
|
.click()
|
|
.then(() => {
|
|
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 attachExcelFile = (excelFilename: string, callback?: any) => {
|
|
cy.get('.buttonBar button:last-child')
|
|
.click()
|
|
.then(() => {
|
|
cy.get('input[type="file"]#file-upload')
|
|
.attachFile(`/${fixturePath}/${excelFilename}`)
|
|
.then(() => {
|
|
cy.get('.modal-footer .btn.btn-primary').then((modalBtn) => {
|
|
modalBtn.click()
|
|
if (callback) callback()
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
const clickOnUploadPreview = (callback?: any) => {
|
|
cy.get('.buttonBar button.btn-primary.btn-upload-preview')
|
|
.click()
|
|
.then(() => {
|
|
if (callback) callback()
|
|
})
|
|
}
|
|
|
|
const confirmEditPreviewFile = (callback?: any) => {
|
|
cy.get('.modal-footer button.btn-success-outline')
|
|
.click()
|
|
.then(() => {
|
|
if (callback) callback()
|
|
})
|
|
}
|
|
|
|
const submitTable = (callback?: any) => {
|
|
cy.get('.btnCtrl button.btn-primary')
|
|
.click()
|
|
.then(() => {
|
|
if (callback) callback()
|
|
})
|
|
}
|
|
|
|
const submitTableMessage = (callback?: any) => {
|
|
cy.get('.modal-footer .btn.btn-sm.btn-success-outline')
|
|
.click()
|
|
.then(() => {
|
|
if (callback) callback()
|
|
})
|
|
}
|
|
|
|
const submitExcel = (callback?: any) => {
|
|
cy.get('.buttonBar button.preview-submit')
|
|
.click()
|
|
.then(() => {
|
|
if (callback) callback()
|
|
})
|
|
}
|
|
|
|
const rejectExcel = (callback?: any) => {
|
|
cy.get('button', { timeout: longerCommandTimeout })
|
|
.should('contain', 'Approve')
|
|
.then((allButtons: any) => {
|
|
for (let approvalButton of allButtons) {
|
|
if (approvalButton.innerText.toLowerCase().includes('approve')) {
|
|
approvalButton.click()
|
|
break
|
|
}
|
|
}
|
|
|
|
cy.get('button.btn-danger')
|
|
.should('exist')
|
|
.should('not.be.disabled')
|
|
.click()
|
|
.then(() => {
|
|
cy.get('.modal-footer button.btn-success-outline')
|
|
.click()
|
|
.then(() => {
|
|
cy.get('app-history')
|
|
.should('exist')
|
|
.then(() => {
|
|
if (callback) callback()
|
|
})
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
const visitPage = (url: string) => {
|
|
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
|
|
}
|