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=', () => { 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}`) }