Files
dc/client/cypress/e2e/editor.cy.ts
T
YuryShkoda 1031ea7ed7
Build / Build-and-ng-test (pull_request) Successful in 5m48s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m28s
Build / Build-and-test-development (pull_request) Successful in 26m35s
feat(editor): translate column names to cell references on formula paste
- Paste a formula using column names (e.g. "=A_COL * B_COL") into any
  cell and have it translated to that row's cell references
  ("=B4 * C4") so HyperFormula can evaluate it - covers both a
  grid-level paste and pasting directly into an open cell editor
- Only applies on tables that already have formulas enabled (an
  existing HARDFORMULA/SOFTFORMULA column); otherwise the pasted text
  is left untouched rather than becoming inert translated text
- New substituteColumnReferences() reuses parseFormulaRule's
  boundary-matching helpers but deliberately skips DC.* variable
  substitution, which only makes sense for admin-defined rule values
2026-08-19 12:48:32 +03:00

1669 lines
61 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.
it("40 | Pasting a formula with column names translates them to this row's cell references and evaluates it, on a table with formulas enabled", () => {
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('=A_COL * B_COL')
getCellByHeaderAndRow(1, 'PLAIN_TEXT_COL').should('have.text', '20')
})
})
})
it('41 | Pasting the same formula text on a table without formulas enabled leaves it as literal, untranslated text', () => {
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',
'=SOME_BESTNUM * SOME_BESTNUM'
)
})
})
})
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, 'PLAIN_TEXT_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, 'PLAIN_TEXT_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, 'PLAIN_TEXT_COL')
.dblclick({ force: true })
.then(() => {
pasteTextIntoFocusedCell('=A_COL * B_COL')
cy.focused().type('{enter}')
getCellByHeaderAndRow(1, 'PLAIN_TEXT_COL').should('have.text', '20')
})
})
})
})
})
// 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}`)
}