Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e516f4012 | ||
|
|
d0a7561f1a | ||
|
|
07d586da52 |
@@ -146,7 +146,7 @@ jobs:
|
||||
# Start frontend and run cypress
|
||||
# timeout 1800: SIGTERM after 30 min so Cypress can flush video/screenshots
|
||||
# before the outer timeout-minutes hard-kills the step (avoids silent multi-hour hangs)
|
||||
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && timeout 1800 npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.cy.ts,cypress/e2e/viewbox.cy.ts,cypress/e2e/stage.cy.ts"
|
||||
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && timeout 1800 npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.cy.ts,cypress/e2e/viewbox.cy.ts"
|
||||
|
||||
- name: Zip Cypress videos
|
||||
if: always()
|
||||
|
||||
@@ -143,7 +143,7 @@ jobs:
|
||||
replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts
|
||||
cat ./cypress.config.ts
|
||||
# Start frontend and run cypress
|
||||
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.cy.ts,cypress/e2e/viewbox.cy.ts,cypress/e2e/stage.cy.ts"
|
||||
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.cy.ts"
|
||||
|
||||
- name: Zip Cypress videos
|
||||
if: always()
|
||||
|
||||
+320
-11
@@ -856,19 +856,19 @@ context('editor tests: ', function () {
|
||||
.and('have.class', 'htCommentCell')
|
||||
.rightclick({ force: true })
|
||||
|
||||
// Only our own "Revert value" 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).
|
||||
// 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 value')
|
||||
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 value').click()
|
||||
cy.get('.htContextMenu').contains('Revert').click()
|
||||
|
||||
getCellByHeaderAndRow(9, 'FORMULA_SOFT_COL')
|
||||
.should('have.text', '2222')
|
||||
@@ -882,9 +882,8 @@ context('editor tests: ', function () {
|
||||
// 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 value") must keep showing the live computed value and the
|
||||
// modified marker, both in the cancelled edit session and back in
|
||||
// read-only view.
|
||||
// "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')
|
||||
|
||||
@@ -939,7 +938,7 @@ context('editor tests: ', function () {
|
||||
getCellByHeaderAndRow(9, 'FORMULA_SOFT_COL').rightclick({
|
||||
force: true
|
||||
})
|
||||
cy.get('.htContextMenu').contains('Revert value').click()
|
||||
cy.get('.htContextMenu').contains('Revert').click()
|
||||
|
||||
getCellByHeaderAndRow(9, 'FORMULA_SOFT_COL')
|
||||
.should('have.text', '2222')
|
||||
@@ -974,6 +973,316 @@ context('editor tests: ', function () {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Handsontable virtualizes columns — with 18 columns on MPE_X_NEW, only
|
||||
@@ -1017,7 +1326,7 @@ const openColumnDropdown = (headerText: string) => {
|
||||
.last()
|
||||
.as('targetHeader')
|
||||
|
||||
cy.get('@targetHeader').click()
|
||||
cy.get('@targetHeader').click({ force: true })
|
||||
cy.get('@targetHeader').find('button.changeType').click({ force: true })
|
||||
}
|
||||
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
const hostUrl = Cypress.env('hosturl')
|
||||
const appLocation = Cypress.env('appLocation')
|
||||
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
|
||||
|
||||
context('stage tests: ', function () {
|
||||
this.beforeAll(() => {
|
||||
cy.visit(`${hostUrl}/SASLogon/logout`)
|
||||
cy.loginAndUpdateValidKey()
|
||||
})
|
||||
|
||||
this.beforeEach(() => {
|
||||
cy.visit(hostUrl + appLocation)
|
||||
|
||||
visitPage('stage/DC20221007T122326121_612316_7259')
|
||||
})
|
||||
|
||||
// getstagetable's mock ignores the table_id param entirely, so any id in
|
||||
// the URL resolves to the same fixture row - no need to submit a real
|
||||
// table first just to reach this page.
|
||||
it('1 | Formatted/Unformatted toggle switches between fmt_stagetable and stagetable, defaulting to formatted', () => {
|
||||
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
|
||||
'not.exist'
|
||||
)
|
||||
|
||||
getCellByHeaderAndRow(0, 'SOME_DATE').should('have.text', '12FEB1960')
|
||||
|
||||
cy.get('.formatted-values-toggle').click()
|
||||
|
||||
getCellByHeaderAndRow(0, 'SOME_DATE').should('have.text', '42')
|
||||
|
||||
// Toggling back reverts to the formatted view.
|
||||
cy.get('.formatted-values-toggle').click()
|
||||
|
||||
getCellByHeaderAndRow(0, 'SOME_DATE').should('have.text', '12FEB1960')
|
||||
})
|
||||
})
|
||||
|
||||
const visitPage = (url: string) => {
|
||||
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
|
||||
}
|
||||
|
||||
// Locates a body cell by its column's header text rather than a hardcoded
|
||||
// childNodes index - same helper as editor.cy.ts's own.
|
||||
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))
|
||||
})
|
||||
}
|
||||
@@ -170,7 +170,7 @@
|
||||
class="card-header clr-row buttonBar headerBar clr-flex-md-row clr-justify-content-center clr-justify-content-lg-end"
|
||||
>
|
||||
@if (tableTrue && !embed) {
|
||||
<div class="clr-col-12 clr-col-md-3 clr-col-lg-4 backBtn">
|
||||
<div class="clr-col-12 clr-col-md-auto clr-col-lg-auto backBtn">
|
||||
<span
|
||||
class="btn icon-collapse btn-sm btn-icon btn-dimmed"
|
||||
[routerLink]="['/home']"
|
||||
@@ -198,7 +198,7 @@
|
||||
}
|
||||
|
||||
<div
|
||||
class="clr-col-12 clr-col-md-5 clr-col-lg-4 d-flex flex-column align-items-center"
|
||||
class="clr-col-12 clr-col-md clr-col-lg d-flex flex-column align-items-center"
|
||||
[class.clr-col-lg-12]="!tableTrue"
|
||||
>
|
||||
<h4
|
||||
@@ -258,12 +258,12 @@
|
||||
</h4>
|
||||
</div>
|
||||
@if (tableTrue) {
|
||||
<div class="clr-col-12 clr-col-md-4 clr-col-lg-4 btnCtrl">
|
||||
<div class="clr-col-12 clr-col-md-auto clr-col-lg-auto btnCtrl">
|
||||
@if (hotTable.readOnly && !uploadPreview) {
|
||||
@if (!isVaEmbed) {
|
||||
<button
|
||||
type="button"
|
||||
class="btnView btn icon-collapse btn-sm btn-icon btn-block btn-dimmed"
|
||||
class="btnView btn icon-collapse btn-sm btn-icon btn-dimmed"
|
||||
(click)="openQb()"
|
||||
>
|
||||
<clr-icon aria-hidden="true" shape="filter"></clr-icon>
|
||||
@@ -272,7 +272,7 @@
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="btn icon-collapse btn-sm btn-primary btn-block"
|
||||
class="btn icon-collapse btn-sm btn-primary"
|
||||
(click)="editTable()"
|
||||
>
|
||||
<clr-icon aria-hidden="true" shape="note"></clr-icon>
|
||||
@@ -282,7 +282,7 @@
|
||||
<button
|
||||
(click)="onShowUploadModal()"
|
||||
type="button"
|
||||
class="btn icon-collapse btn-sm btn-success btn-block mr-0"
|
||||
class="btn icon-collapse btn-sm btn-success mr-0"
|
||||
>
|
||||
<clr-icon aria-hidden="true" shape="upload"></clr-icon>
|
||||
<span class="text">Upload</span>
|
||||
|
||||
@@ -41,9 +41,13 @@ import { LoggerService } from '../services/logger.service'
|
||||
import { SasService } from '../services/sas.service'
|
||||
import { UserService } from '../shared/user.service'
|
||||
import { applyFormulaRules } from '../shared/dc-validator/utils/applyFormulaRules'
|
||||
import { findFormulaValueChanges } from '../shared/dc-validator/utils/findFormulaValueChanges'
|
||||
import { expandCellRanges } from './utils/expandCellRanges'
|
||||
import { preventMenuItemAutoClose } from './utils/preventMenuItemAutoClose'
|
||||
import { findOverwrittenCells } from '../shared/dc-validator/utils/findOverwrittenCells'
|
||||
import { getFormulaCellsToPreserveOnCancel } from '../shared/dc-validator/utils/getFormulaCellsToPreserveOnCancel'
|
||||
import { getRevertableCols } from '../shared/dc-validator/utils/getRevertableCols'
|
||||
import { getStableFormulaBaseCols } from '../shared/dc-validator/utils/getStableFormulaBaseCols'
|
||||
import { syncOverwrittenCellComment } from '../shared/dc-validator/utils/syncOverwrittenCellComment'
|
||||
import { parseFormulaRule } from '../shared/dc-validator/utils/parseFormulaRule'
|
||||
import { DcValidator } from '../shared/dc-validator/dc-validator'
|
||||
import { Col } from '../shared/dc-validator/models/col.model'
|
||||
@@ -204,49 +208,63 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
},
|
||||
// Only ever shown for a cell markFormulaChangedCells attached a
|
||||
// comment to (a HARDFORMULA/SOFTFORMULA cell whose formula
|
||||
// overwrote a real, pre-existing value) - the comment's presence
|
||||
// is a sufficient and exact signal, no need to separately
|
||||
// re-check the column against the DQ rules here too.
|
||||
revert_formula_value: {
|
||||
name: 'Revert value',
|
||||
// Only ever shown when the selection contains at least one cell
|
||||
// markOverwrittenCells (or the live afterChange sync) attached a
|
||||
// comment to - i.e. a cell whose current value differs from what
|
||||
// SAS actually sent for it, formula-caused or a direct edit. The
|
||||
// comment's presence is a sufficient and exact signal, no need to
|
||||
// separately recompute "is this overwritten" here too. The
|
||||
// selection can be a single cell, a rectangular multi-cell range,
|
||||
// a whole row/column (header click), or several disjoint ranges
|
||||
// (ctrl-click) - expandCellRanges normalizes all of those into a
|
||||
// flat list of individual cells.
|
||||
revert_cells: {
|
||||
name: 'Revert',
|
||||
hidden(this: Handsontable.Core) {
|
||||
if (this.getSettings().readOnly) return true
|
||||
|
||||
const fullCellRange: CellRange[] | undefined =
|
||||
this.getSelectedRange()
|
||||
if (!fullCellRange) return true
|
||||
|
||||
const { from, to } = fullCellRange[0]
|
||||
if (from.row !== to.row || from.col !== to.col) return true
|
||||
const ranges: CellRange[] | undefined = this.getSelectedRange()
|
||||
if (!ranges || ranges.length === 0) return true
|
||||
|
||||
const commentsPlugin: any = this.getPlugin('comments')
|
||||
const cells = expandCellRanges(
|
||||
ranges,
|
||||
this.countRows(),
|
||||
this.countCols()
|
||||
)
|
||||
|
||||
return !commentsPlugin.getCommentAtCell(from.row, from.col)
|
||||
return !cells.some(({ row, col }) =>
|
||||
commentsPlugin.getCommentAtCell(row, col)
|
||||
)
|
||||
},
|
||||
callback: (key: string, selection: any[]) => {
|
||||
const hot = this.hotInstance
|
||||
const { row, col } = selection[0].start
|
||||
const prop = hot.colToProp(col) as string
|
||||
|
||||
const commentsPlugin: any = hot.getPlugin('comments')
|
||||
const comment: string | undefined =
|
||||
commentsPlugin.getCommentAtCell(row, col)
|
||||
if (!comment) return
|
||||
|
||||
const rawValueText = comment.slice(
|
||||
EditorComponent.ORIGINAL_VALUE_COMMENT_PREFIX.length
|
||||
const cells = expandCellRanges(
|
||||
selection.map((sel) => ({ from: sel.start, to: sel.end })),
|
||||
hot.countRows(),
|
||||
hot.countCols()
|
||||
)
|
||||
const isNumericCol =
|
||||
this.dcValidator?.getRule(prop)?.type === 'numeric'
|
||||
|
||||
hot.setDataAtRowProp(
|
||||
row,
|
||||
prop,
|
||||
isNumericCol ? Number(rawValueText) : rawValueText
|
||||
)
|
||||
commentsPlugin.removeCommentAtCell(row, col)
|
||||
for (const { row, col } of cells) {
|
||||
const comment: string | undefined =
|
||||
commentsPlugin.getCommentAtCell(row, col)
|
||||
if (!comment) continue
|
||||
|
||||
const prop = hot.colToProp(col) as string
|
||||
const rawValueText = comment.slice(
|
||||
EditorComponent.ORIGINAL_VALUE_COMMENT_PREFIX.length
|
||||
)
|
||||
const isNumericCol =
|
||||
this.dcValidator?.getRule(prop)?.type === 'numeric'
|
||||
|
||||
hot.setDataAtRowProp(
|
||||
row,
|
||||
prop,
|
||||
isNumericCol ? Number(rawValueText) : rawValueText
|
||||
)
|
||||
commentsPlugin.removeCommentAtCell(row, col)
|
||||
}
|
||||
}
|
||||
},
|
||||
row_above: {
|
||||
@@ -405,9 +423,9 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
* with that cadence.
|
||||
*/
|
||||
private static readonly VA_DEBOUNCE_MS = 800
|
||||
// Shared between markFormulaChangedCells (writes it) and the
|
||||
// revert_formula_value context menu item (parses it back out) - see
|
||||
// findFormulaValueChanges.
|
||||
// Shared between markOverwrittenCells/syncOverwrittenCommentForCell
|
||||
// (writes it) and the revert_cells context menu item (parses it back
|
||||
// out) - see findOverwrittenCells.
|
||||
private static readonly ORIGINAL_VALUE_COMMENT_PREFIX = 'Original value: '
|
||||
public tableTrue: boolean | undefined
|
||||
public saveLoading = false
|
||||
@@ -464,8 +482,8 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
prevDataSource!: any[]
|
||||
dataSourceUnchanged!: any[]
|
||||
// Raw, as-received-from-SAS snapshot, captured before applyFormulaRules
|
||||
// overwrites HARDFORMULA/SOFTFORMULA columns - see findFormulaValueChanges
|
||||
// and getFormulaBaseCols. Distinct from dataSourceUnchanged, which is a
|
||||
// overwrites HARDFORMULA/SOFTFORMULA columns - see findOverwrittenCells
|
||||
// and getRevertableColumnNames. Distinct from dataSourceUnchanged, which is a
|
||||
// per-editing-session baseline that gets reset on every editTable() call;
|
||||
// this stays fixed for the table's whole lifetime, since "what did the
|
||||
// real dataset actually have before any formula got involved" doesn't
|
||||
@@ -1109,6 +1127,17 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
hot.render()
|
||||
|
||||
// Resync every revertable cell's "overwritten" comment against
|
||||
// dataSourceRaw on every entry into edit mode. Needed for the Excel
|
||||
// upload path in particular (previewTableEditConfirm -> editTable(true))
|
||||
// - the preview's bulk hot.updateSettings({data: ...}) fires afterChange
|
||||
// with source 'loadData', which the live sync hook deliberately ignores
|
||||
// (see its own comment), so an uploaded value that differs from the
|
||||
// original SAS data would otherwise never get flagged/commented. A
|
||||
// plain Edit-button click re-syncs the same (already-correct) state,
|
||||
// same as cancelEdit() does for the read-only return path.
|
||||
this.syncOverwrittenComments()
|
||||
|
||||
for (const sortConfig of sortConfigs) {
|
||||
columnSorting.sort(sortConfig)
|
||||
}
|
||||
@@ -1162,7 +1191,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
if (this.dataSourceUnchanged) {
|
||||
// dataSourceUnchanged deliberately holds the RAW pre-formula value for
|
||||
// a HARDFORMULA/SOFTFORMULA column that still has its
|
||||
// markFormulaChangedCells comment (see editTable's overlay) - needed
|
||||
// markOverwrittenCells comment (see editTable's overlay) - needed
|
||||
// so classifyRow flags the row as modified, but it isn't a session
|
||||
// edit to discard: the formula recomputes on every load regardless.
|
||||
// Snapshot those cells' live computed value before the blind restore
|
||||
@@ -1201,6 +1230,14 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
false
|
||||
)
|
||||
|
||||
// A cell's overwritten comment can be stale after this restore - e.g.
|
||||
// a direct edit (not a formula overwrite) just got discarded, and
|
||||
// dataSource now matches dataSourceRaw again for it. formula cells'
|
||||
// comments are already correct (preserved above), so this is mostly
|
||||
// about the general case, but running the same full resync either way
|
||||
// is simpler than trying to only check what might have changed.
|
||||
this.syncOverwrittenComments()
|
||||
|
||||
this.modifedRowsIndexes = []
|
||||
hot.validateCells()
|
||||
// this.editRecordListeners();
|
||||
@@ -1497,8 +1534,11 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
* BASE_COL names of every HARDFORMULA/SOFTFORMULA rule whose result is
|
||||
* expected to be *stable* for a given row (see getStableFormulaBaseCols
|
||||
* for why DC.USER_NAME/DC.ORIG_VALUE/DC.ROW_STATUS-based rules are
|
||||
* excluded). Used by markFormulaChangedCells and the dataSourceUnchanged
|
||||
* overlay in editTable() - NOT the same filter seedFormulaValuesForRow
|
||||
* excluded). Used only by overlayFormulaRawValuesOnUnchanged and
|
||||
* cancelEdit's preserve-on-cancel patch, which exist specifically to work
|
||||
* around dataSourceUnchanged's formula-only raw overlay - see those
|
||||
* methods' own doc comments for why that stays narrower than
|
||||
* getRevertableColumnNames(). NOT the same filter seedFormulaValuesForRow
|
||||
* uses above, which seeds every formula column regardless of this
|
||||
* distinction.
|
||||
*/
|
||||
@@ -1512,7 +1552,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
/**
|
||||
* Overlays the true raw (pre-formula) value onto dataSourceUnchanged for
|
||||
* every HARDFORMULA/SOFTFORMULA base col that already had real data (see
|
||||
* markFormulaChangedCells) - mutates in place. A HARDFORMULA/SOFTFORMULA
|
||||
* markOverwrittenCells) - mutates in place. A HARDFORMULA/SOFTFORMULA
|
||||
* rule can silently overwrite a value that already existed in the real
|
||||
* dataset - that's a real change classifyRow's diff should pick up, not
|
||||
* something that only becomes visible once an edit session starts. Shared
|
||||
@@ -1538,59 +1578,129 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks every cell where a HARDFORMULA/SOFTFORMULA rule silently changed
|
||||
* a value that already existed in the real dataset (see
|
||||
* findFormulaValueChanges) with a read-only comment showing the original
|
||||
* value - otherwise there's no visual difference between "the formula
|
||||
* just filled in a blank" and "the formula overwrote real data no one
|
||||
* asked to change". Must run after hot.updateSettings() has processed the
|
||||
* formulas: getDataAtRowProp only resolves the live computed value once
|
||||
* HyperFormula has actually evaluated the cell, not the raw formula
|
||||
* string dataSource holds right after applyFormulaRules.
|
||||
* Every column eligible to be checked/marked as "overwritten" - every
|
||||
* revertable column (see getRevertableCols), not just the narrower
|
||||
* formula-only set getFormulaBaseCols() returns. Used by
|
||||
* markOverwrittenCells and the live afterChange sync - NOT by
|
||||
* overlayFormulaRawValuesOnUnchanged/cancelEdit's preserve-on-cancel
|
||||
* patch, which stay scoped to getFormulaBaseCols() specifically (see
|
||||
* those methods' own doc comments for why).
|
||||
*/
|
||||
private getRevertableColumnNames(): string[] {
|
||||
const dqRules = this.dcValidator?.getDqDetails()
|
||||
if (!dqRules) return []
|
||||
|
||||
return getRevertableCols(dqRules, this.headerColumns)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets or clears a single cell's "overwritten" comment, matching its
|
||||
* current value against dataSourceRaw (PK-matched, so this is safe to
|
||||
* call after a row insert/delete/sort has moved things around). No-op if
|
||||
* the row has no PK match in dataSourceRaw (a newly-inserted row) - see
|
||||
* findOverwrittenCells for why that's never revertable.
|
||||
*/
|
||||
private syncOverwrittenCommentForCell(rowIndex: number, prop: string): void {
|
||||
const hot = this.hotInstance
|
||||
const dataRow = this.dataSource[rowIndex]
|
||||
if (!dataRow || !this.dataSourceRaw) return
|
||||
|
||||
const rawRow = this.dataSourceRaw.find((candidate) =>
|
||||
this.headerPks.every((pk) => candidate[pk] === dataRow[pk])
|
||||
)
|
||||
if (!rawRow) return
|
||||
|
||||
const colIndex = hot.propToCol(prop) as number
|
||||
const commentsPlugin = hot.getPlugin('comments')
|
||||
const currentValue = hot.getDataAtRowProp(rowIndex, prop)
|
||||
const hasCommentAlready = !!commentsPlugin.getCommentAtCell(
|
||||
rowIndex,
|
||||
colIndex
|
||||
)
|
||||
|
||||
const action = syncOverwrittenCellComment(
|
||||
currentValue,
|
||||
rawRow[prop],
|
||||
hasCommentAlready
|
||||
)
|
||||
|
||||
if (action === 'set') {
|
||||
commentsPlugin.setCommentAtCell(
|
||||
rowIndex,
|
||||
colIndex,
|
||||
`${EditorComponent.ORIGINAL_VALUE_COMMENT_PREFIX}${rawRow[prop]}`
|
||||
)
|
||||
} else if (action === 'remove') {
|
||||
commentsPlugin.removeCommentAtCell(rowIndex, colIndex)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full resync of every revertable cell's "overwritten" comment against
|
||||
* the current data. Used both at initial load (nothing has a comment
|
||||
* yet, so this is purely additive) and after Cancel (an edit may have
|
||||
* just been discarded, so a previously-set comment can now be stale) -
|
||||
* Handsontable's comments plugin has no way to enumerate its own
|
||||
* comments, so the only way to find a stale one is to re-check every
|
||||
* candidate cell.
|
||||
*/
|
||||
private syncOverwrittenComments(): void {
|
||||
const revertableCols = this.getRevertableColumnNames()
|
||||
if (revertableCols.length === 0 || !this.dataSourceRaw) return
|
||||
|
||||
this.dataSource.forEach((_row, rowIndex) => {
|
||||
for (const col of revertableCols) {
|
||||
this.syncOverwrittenCommentForCell(rowIndex, col)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks every cell whose current value differs from what SAS actually
|
||||
* sent for it (see findOverwrittenCells) with a read-only comment
|
||||
* showing the original value - a HARDFORMULA/SOFTFORMULA rule silently
|
||||
* overwriting real pre-existing data is one way this happens, but so is
|
||||
* any direct edit; both need the same "here's what it used to be, and a
|
||||
* way back" treatment. Must run after hot.updateSettings() has processed
|
||||
* the formulas: getDataAtRowProp only resolves a formula cell's live
|
||||
* computed value once HyperFormula has actually evaluated it, not the
|
||||
* raw formula string dataSource holds right after applyFormulaRules.
|
||||
*
|
||||
* Also flips each affected row's EDIT_STATUS to 'M' - this is a real,
|
||||
* permanent difference from the raw dataset (every future load recomputes
|
||||
* the same formula again), not a session edit afterChange would ever see,
|
||||
* so nothing else would otherwise mark these rows modified. Doing this
|
||||
* once here, before dataSourceUnchanged is ever cloned from dataSource,
|
||||
* means the 'M' baseline is already shared by both snapshots - no special
|
||||
* handling needed to keep it from being wiped out by a later cancelEdit().
|
||||
* permanent difference from the raw dataset (every future load
|
||||
* recomputes the same formula again, and a direct edit made before this
|
||||
* method ever ran isn't something afterChange would have seen), so
|
||||
* nothing else would otherwise mark these rows modified. Doing this once
|
||||
* here, before dataSourceUnchanged is ever cloned from dataSource, means
|
||||
* the 'M' baseline is already shared by both snapshots - no special
|
||||
* handling needed to keep it from being wiped out by a later
|
||||
* cancelEdit().
|
||||
*/
|
||||
private markFormulaChangedCells(): void {
|
||||
private markOverwrittenCells(): void {
|
||||
this.syncOverwrittenComments()
|
||||
|
||||
const hot = this.hotInstance
|
||||
const formulaBaseCols = this.getFormulaBaseCols()
|
||||
if (formulaBaseCols.length === 0 || !this.dataSourceRaw) return
|
||||
const revertableCols = this.getRevertableColumnNames()
|
||||
if (revertableCols.length === 0 || !this.dataSourceRaw) return
|
||||
|
||||
const computedRows = this.dataSource.map((_row, rowIndex) =>
|
||||
Object.fromEntries(
|
||||
formulaBaseCols.map((baseCol) => [
|
||||
baseCol,
|
||||
hot.getDataAtRowProp(rowIndex, baseCol)
|
||||
])
|
||||
const currentRows = this.dataSource.map((row, rowIndex) => ({
|
||||
...row,
|
||||
...Object.fromEntries(
|
||||
revertableCols.map((col) => [col, hot.getDataAtRowProp(rowIndex, col)])
|
||||
)
|
||||
)
|
||||
}))
|
||||
|
||||
const changes = findFormulaValueChanges(
|
||||
computedRows,
|
||||
const changes = findOverwrittenCells(
|
||||
currentRows,
|
||||
this.dataSourceRaw,
|
||||
formulaBaseCols
|
||||
revertableCols,
|
||||
this.headerPks
|
||||
)
|
||||
|
||||
const commentsPlugin = hot.getPlugin('comments')
|
||||
const changedRows = new Set<number>()
|
||||
|
||||
for (const change of changes) {
|
||||
commentsPlugin.setCommentAtCell(
|
||||
change.rowIndex,
|
||||
hot.propToCol(change.baseCol) as number,
|
||||
`${EditorComponent.ORIGINAL_VALUE_COMMENT_PREFIX}${change.originalValue}`
|
||||
)
|
||||
changedRows.add(change.rowIndex)
|
||||
}
|
||||
const changedRows = new Set(changes.map((change) => change.rowIndex))
|
||||
|
||||
// Not classifyRow/updateEditStatusForRow - dataSourceUnchanged doesn't
|
||||
// exist yet this early (only editTable() sets it), and these rows are
|
||||
// already known-modified from the comment loop above; no need to
|
||||
// already known-modified from the comment sync above; no need to
|
||||
// reclassify.
|
||||
//
|
||||
// Deferred: setDataAtRowProp needs the Formulas plugin's hidden-column
|
||||
@@ -3490,7 +3600,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.$dataFormats = response.data.$sasdata
|
||||
|
||||
// Raw, as-received snapshot - captured before applyFormulaRules below
|
||||
// overwrites HARDFORMULA/SOFTFORMULA columns, so markFormulaChangedCells
|
||||
// overwrites HARDFORMULA/SOFTFORMULA columns, so markOverwrittenCells
|
||||
// can later tell "the formula filled in a blank" apart from "the
|
||||
// formula overwrote a value the real dataset already had".
|
||||
this.dataSourceRaw = this.helperService.deepClone(this.dataSource)
|
||||
@@ -3520,7 +3630,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
// Seeded here too (not just editTable()) so a HARDFORMULA/SOFTFORMULA
|
||||
// rule that silently overwrote real pre-existing data (see
|
||||
// markFormulaChangedCells) already shows the row as modified - both the
|
||||
// markOverwrittenCells) already shows the row as modified - both the
|
||||
// '~' row header and EDIT_STATUS='M' - on the very first render, before
|
||||
// the user ever clicks Edit. editTable() rebuilds this fresh every time
|
||||
// it runs regardless, so setting it here doesn't affect that.
|
||||
@@ -3573,9 +3683,10 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
// collision-proof dotted name.
|
||||
dataDotNotation: false,
|
||||
// readOnly here means comments can never be added/edited/removed
|
||||
// through the UI - only markFormulaChangedCells (via the plugin
|
||||
// API) ever sets one. The context menu below only offers our own
|
||||
// "Revert value" item, never the plugin's own add/edit/remove ones.
|
||||
// through the UI - only markOverwrittenCells/
|
||||
// syncOverwrittenCommentForCell (via the plugin API) ever set one.
|
||||
// The context menu below only offers our own "Revert" item, never
|
||||
// the plugin's own add/edit/remove ones.
|
||||
comments: { readOnly: true },
|
||||
stretchH: 'all',
|
||||
readOnly: this.hotTable.readOnly,
|
||||
@@ -3650,6 +3761,12 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
},
|
||||
info: {
|
||||
name: 'test info',
|
||||
// Purely informational (no callback) - without this, Handsontable
|
||||
// still treats a click landing anywhere inside it as "the item
|
||||
// was activated" (see preventMenuItemAutoClose's own comment),
|
||||
// auto-closing the menu and blocking the native right-click
|
||||
// "Copy" menu before the user can select any of the text.
|
||||
isCommand: false,
|
||||
renderer: (
|
||||
hot: Handsontable.Core,
|
||||
wrapper: HTMLElement,
|
||||
@@ -3689,6 +3806,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
elem.innerHTML = textInfo
|
||||
preventMenuItemAutoClose(elem)
|
||||
|
||||
return elem
|
||||
}
|
||||
@@ -3778,7 +3896,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
// resolves formulas' live computed values once HyperFormula has
|
||||
// actually evaluated them against the data/formulas settings just
|
||||
// applied.
|
||||
this.markFormulaChangedCells()
|
||||
this.markOverwrittenCells()
|
||||
|
||||
this.hotTable.hidden = false
|
||||
// Keep the context menu enabled in view mode too so Copy/Export remain
|
||||
@@ -3912,9 +4030,16 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
// (initial load, cancelSubmit, ...) where dataSource is already
|
||||
// consistent, so it's skipped; 'editStatus' is this same hook's own
|
||||
// writes (via updateEditStatusForRow), skipped to avoid recursion.
|
||||
//
|
||||
// Also keeps each edited cell's "overwritten" comment in sync live -
|
||||
// markOverwrittenCells only runs once, at initial load, so a direct
|
||||
// edit made afterward (to a cell that wasn't already overwritten by a
|
||||
// formula) needs its own comment set here; typing a value back to
|
||||
// match the original just as readily needs that comment removed again.
|
||||
hot.addHook('afterChange', (changes: any[], source: any) => {
|
||||
if (!changes || source === 'loadData' || source === 'editStatus') return
|
||||
|
||||
const revertableCols = this.getRevertableColumnNames()
|
||||
const changedRows = new Set<number>()
|
||||
for (const change of changes) {
|
||||
if (!change) continue
|
||||
@@ -3923,6 +4048,10 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
if (prop === EDIT_STATUS_COLUMN_NAME) continue
|
||||
|
||||
changedRows.add(row)
|
||||
|
||||
if (revertableCols.includes(prop)) {
|
||||
this.syncOverwrittenCommentForCell(row, prop)
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of changedRows) this.updateEditStatusForRow(row)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { expandCellRanges } from './expandCellRanges'
|
||||
|
||||
describe('expandCellRanges', () => {
|
||||
it('expands a single-cell range', () => {
|
||||
expect(
|
||||
expandCellRanges(
|
||||
[{ from: { row: 1, col: 1 }, to: { row: 1, col: 1 } }],
|
||||
3,
|
||||
3
|
||||
)
|
||||
).toEqual([{ row: 1, col: 1 }])
|
||||
})
|
||||
|
||||
it('expands a rectangular multi-cell range', () => {
|
||||
expect(
|
||||
expandCellRanges(
|
||||
[{ from: { row: 0, col: 0 }, to: { row: 1, col: 1 } }],
|
||||
3,
|
||||
3
|
||||
)
|
||||
).toEqual([
|
||||
{ row: 0, col: 0 },
|
||||
{ row: 0, col: 1 },
|
||||
{ row: 1, col: 0 },
|
||||
{ row: 1, col: 1 }
|
||||
])
|
||||
})
|
||||
|
||||
it('normalizes a whole-row selection (from.col === -1) to every real column', () => {
|
||||
expect(
|
||||
expandCellRanges(
|
||||
[{ from: { row: 1, col: -1 }, to: { row: 1, col: 2 } }],
|
||||
3,
|
||||
3
|
||||
)
|
||||
).toEqual([
|
||||
{ row: 1, col: 0 },
|
||||
{ row: 1, col: 1 },
|
||||
{ row: 1, col: 2 }
|
||||
])
|
||||
})
|
||||
|
||||
it('normalizes a whole-column selection (from.row === -1) to every real row', () => {
|
||||
expect(
|
||||
expandCellRanges(
|
||||
[{ from: { row: -1, col: 1 }, to: { row: 2, col: 1 } }],
|
||||
3,
|
||||
3
|
||||
)
|
||||
).toEqual([
|
||||
{ row: 0, col: 1 },
|
||||
{ row: 1, col: 1 },
|
||||
{ row: 2, col: 1 }
|
||||
])
|
||||
})
|
||||
|
||||
it('expands every disjoint range when multiple are given (ctrl-click)', () => {
|
||||
expect(
|
||||
expandCellRanges(
|
||||
[
|
||||
{ from: { row: 0, col: 0 }, to: { row: 0, col: 0 } },
|
||||
{ from: { row: 2, col: 2 }, to: { row: 2, col: 2 } }
|
||||
],
|
||||
3,
|
||||
3
|
||||
)
|
||||
).toEqual([
|
||||
{ row: 0, col: 0 },
|
||||
{ row: 2, col: 2 }
|
||||
])
|
||||
})
|
||||
|
||||
it('treats a null row/col the same as -1 (Handsontable.CellRange types these as number | null)', () => {
|
||||
expect(
|
||||
expandCellRanges(
|
||||
[{ from: { row: 1, col: null }, to: { row: 1, col: 2 } }],
|
||||
3,
|
||||
3
|
||||
)
|
||||
).toEqual([
|
||||
{ row: 1, col: 0 },
|
||||
{ row: 1, col: 1 },
|
||||
{ row: 1, col: 2 }
|
||||
])
|
||||
})
|
||||
|
||||
it('handles from/to given in reverse order (drag selection upward/leftward)', () => {
|
||||
expect(
|
||||
expandCellRanges(
|
||||
[{ from: { row: 2, col: 2 }, to: { row: 1, col: 1 } }],
|
||||
3,
|
||||
3
|
||||
)
|
||||
).toEqual([
|
||||
{ row: 1, col: 1 },
|
||||
{ row: 1, col: 2 },
|
||||
{ row: 2, col: 1 },
|
||||
{ row: 2, col: 2 }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
export interface SimpleCellRange {
|
||||
from: { row: number | null; col: number | null }
|
||||
to: { row: number | null; col: number | null }
|
||||
}
|
||||
|
||||
export interface SimpleCell {
|
||||
row: number
|
||||
col: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands one or more Handsontable selection ranges (as returned by
|
||||
* getSelectedRange()/the context menu callback's selection argument) into
|
||||
* every individual (row, col) cell they cover. A whole-row selection (row
|
||||
* header click) reports col: -1 (Handsontable.CellRange itself types this
|
||||
* as number | null, so null is treated the same way here) on whichever end
|
||||
* is the "start" of the range; a whole-column selection reports row: -1
|
||||
* the same way - normalized here to the real 0..totalRows-1/0..totalCols-1
|
||||
* bounds, since neither -1 nor null is a usable index for anything
|
||||
* downstream (comments lookup, setDataAtRowProp, ...). Multiple ranges
|
||||
* (ctrl-click) are all expanded, not just the first.
|
||||
*/
|
||||
export const expandCellRanges = (
|
||||
ranges: SimpleCellRange[],
|
||||
totalRows: number,
|
||||
totalCols: number
|
||||
): SimpleCell[] => {
|
||||
const cells: SimpleCell[] = []
|
||||
|
||||
const normalizeRow = (row: number | null) =>
|
||||
row === -1 || row === null ? undefined : row
|
||||
const normalizeCol = (col: number | null) =>
|
||||
col === -1 || col === null ? undefined : col
|
||||
|
||||
for (const range of ranges) {
|
||||
const rowA = normalizeRow(range.from.row) ?? 0
|
||||
const rowB = normalizeRow(range.to.row) ?? totalRows - 1
|
||||
const colA = normalizeCol(range.from.col) ?? 0
|
||||
const colB = normalizeCol(range.to.col) ?? totalCols - 1
|
||||
|
||||
const startRow = Math.min(rowA, rowB)
|
||||
const endRow = Math.max(rowA, rowB)
|
||||
const startCol = Math.min(colA, colB)
|
||||
const endCol = Math.max(colA, colB)
|
||||
|
||||
for (let row = startRow; row <= endRow; row++) {
|
||||
for (let col = startCol; col <= endCol; col++) {
|
||||
cells.push({ row, col })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cells
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { preventMenuItemAutoClose } from './preventMenuItemAutoClose'
|
||||
|
||||
describe('preventMenuItemAutoClose', () => {
|
||||
let parent: HTMLElement
|
||||
let child: HTMLElement
|
||||
|
||||
beforeEach(() => {
|
||||
parent = document.createElement('div')
|
||||
child = document.createElement('span')
|
||||
parent.appendChild(child)
|
||||
document.body.appendChild(parent)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeChild(parent)
|
||||
})
|
||||
|
||||
const bubblingEvents = ['mousedown', 'mouseup', 'contextmenu', 'selectstart']
|
||||
|
||||
bubblingEvents.forEach((eventName) => {
|
||||
it(`stops a ${eventName} dispatched on the element from bubbling to its parent`, () => {
|
||||
let bubbledToParent = false
|
||||
parent.addEventListener(eventName, () => (bubbledToParent = true))
|
||||
|
||||
preventMenuItemAutoClose(child)
|
||||
child.dispatchEvent(new Event(eventName, { bubbles: true }))
|
||||
|
||||
expect(bubbledToParent).toBe(false)
|
||||
})
|
||||
|
||||
it(`still lets a ${eventName} dispatched directly on the parent reach the parent (propagation isn't globally broken)`, () => {
|
||||
let bubbledToParent = false
|
||||
parent.addEventListener(eventName, () => (bubbledToParent = true))
|
||||
|
||||
preventMenuItemAutoClose(child)
|
||||
parent.dispatchEvent(new Event(eventName, { bubbles: true }))
|
||||
|
||||
expect(bubbledToParent).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not call preventDefault on contextmenu, so the browser can still show its own menu', () => {
|
||||
preventMenuItemAutoClose(child)
|
||||
|
||||
const event = new Event('contextmenu', {
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
child.dispatchEvent(event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
// Handsontable's Menu widget (used by both dropdownMenu and contextMenu)
|
||||
// treats a click landing anywhere inside a non-passive item as "the item was
|
||||
// activated": mouseup auto-closes the menu, and contextmenu is
|
||||
// unconditionally preventDefault()'d, so the browser's own right-click menu
|
||||
// never appears - regardless of whether the item actually has a callback.
|
||||
// A custom-rendered, read-only item (plain informational text, nothing to
|
||||
// click) still gets this treatment, making its content impossible to select
|
||||
// or copy. Handsontable's own listeners are bubble-phase, attached on
|
||||
// ancestors of the rendered item element, so stopping propagation at the
|
||||
// item itself is enough to reach them before they run - no capture-phase
|
||||
// handling needed.
|
||||
const EVENTS_TO_ISOLATE = [
|
||||
'mousedown',
|
||||
'mouseup',
|
||||
'contextmenu',
|
||||
'selectstart'
|
||||
] as const
|
||||
|
||||
export const preventMenuItemAutoClose = (elem: HTMLElement): void => {
|
||||
for (const eventName of EVENTS_TO_ISOLATE) {
|
||||
elem.addEventListener(eventName, (event) => event.stopPropagation())
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { findFormulaValueChanges } from './findFormulaValueChanges'
|
||||
|
||||
describe('findFormulaValueChanges', () => {
|
||||
it('reports a change when the computed value differs from a meaningful raw value', () => {
|
||||
const computedRows = [{ FORMULA_HARD_COL: 20, FORMULA_SOFT_COL: 12 }]
|
||||
const rawRows = [{ FORMULA_HARD_COL: 1111, FORMULA_SOFT_COL: 2222 }]
|
||||
|
||||
expect(
|
||||
findFormulaValueChanges(computedRows, rawRows, [
|
||||
'FORMULA_HARD_COL',
|
||||
'FORMULA_SOFT_COL'
|
||||
])
|
||||
).toEqual([
|
||||
{ rowIndex: 0, baseCol: 'FORMULA_HARD_COL', originalValue: 1111 },
|
||||
{ rowIndex: 0, baseCol: 'FORMULA_SOFT_COL', originalValue: 2222 }
|
||||
])
|
||||
})
|
||||
|
||||
it('reports nothing when the computed value matches the raw value', () => {
|
||||
const computedRows = [{ FORMULA_HARD_COL: 20 }]
|
||||
const rawRows = [{ FORMULA_HARD_COL: 20 }]
|
||||
|
||||
expect(
|
||||
findFormulaValueChanges(computedRows, rawRows, ['FORMULA_HARD_COL'])
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('compares loosely (string vs number) so a "20" raw value matching a 20 computed value is not reported', () => {
|
||||
const computedRows = [{ FORMULA_HARD_COL: 20 }]
|
||||
const rawRows = [{ FORMULA_HARD_COL: '20' }]
|
||||
|
||||
expect(
|
||||
findFormulaValueChanges(computedRows, rawRows, ['FORMULA_HARD_COL'])
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores a column with no meaningful raw value (blank/null/undefined) - nothing to have changed from', () => {
|
||||
const computedRows = [
|
||||
{ FORMULA_HARD_COL: 20 },
|
||||
{ FORMULA_HARD_COL: 20 },
|
||||
{ FORMULA_HARD_COL: 20 }
|
||||
]
|
||||
const rawRows = [
|
||||
{ FORMULA_HARD_COL: '' },
|
||||
{ FORMULA_HARD_COL: null },
|
||||
{ FORMULA_HARD_COL: undefined }
|
||||
]
|
||||
|
||||
expect(
|
||||
findFormulaValueChanges(computedRows, rawRows, ['FORMULA_HARD_COL'])
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('only reports the rows/columns that actually changed, across multiple rows', () => {
|
||||
const computedRows = [
|
||||
{ FORMULA_HARD_COL: 20, FORMULA_SOFT_COL: 12 },
|
||||
{ FORMULA_HARD_COL: 30, FORMULA_SOFT_COL: 13 }
|
||||
]
|
||||
const rawRows = [
|
||||
{ FORMULA_HARD_COL: 20, FORMULA_SOFT_COL: 2222 },
|
||||
{ FORMULA_HARD_COL: 30, FORMULA_SOFT_COL: 13 }
|
||||
]
|
||||
|
||||
expect(
|
||||
findFormulaValueChanges(computedRows, rawRows, [
|
||||
'FORMULA_HARD_COL',
|
||||
'FORMULA_SOFT_COL'
|
||||
])
|
||||
).toEqual([
|
||||
{ rowIndex: 0, baseCol: 'FORMULA_SOFT_COL', originalValue: 2222 }
|
||||
])
|
||||
})
|
||||
|
||||
it('is safe when a raw row is missing (e.g. array length mismatch) - skips it rather than throwing', () => {
|
||||
const computedRows = [{ FORMULA_HARD_COL: 20 }, { FORMULA_HARD_COL: 30 }]
|
||||
const rawRows = [{ FORMULA_HARD_COL: 1111 }]
|
||||
|
||||
expect(
|
||||
findFormulaValueChanges(computedRows, rawRows, ['FORMULA_HARD_COL'])
|
||||
).toEqual([
|
||||
{ rowIndex: 0, baseCol: 'FORMULA_HARD_COL', originalValue: 1111 }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,40 +0,0 @@
|
||||
export interface FormulaValueChange {
|
||||
rowIndex: number
|
||||
baseCol: string
|
||||
originalValue: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds every (row, HARDFORMULA/SOFTFORMULA column) pair where the value
|
||||
* computed by the formula differs from the real, raw value the dataset
|
||||
* already had for that cell - i.e. adding the formula rule silently changed
|
||||
* a value that pre-existed in the actual data, not just filled in a blank.
|
||||
* Compared loosely (via string coercion) since the raw value arrives as
|
||||
* whatever type SAS sent while the computed value is HyperFormula's own
|
||||
* (often numeric) result for the same underlying number.
|
||||
*/
|
||||
export const findFormulaValueChanges = (
|
||||
computedRows: Record<string, unknown>[],
|
||||
rawRows: Record<string, unknown>[],
|
||||
formulaBaseCols: string[]
|
||||
): FormulaValueChange[] => {
|
||||
const changes: FormulaValueChange[] = []
|
||||
|
||||
computedRows.forEach((row, rowIndex) => {
|
||||
const rawRow = rawRows[rowIndex]
|
||||
if (!rawRow) return
|
||||
|
||||
for (const baseCol of formulaBaseCols) {
|
||||
const rawValue = rawRow[baseCol]
|
||||
if (rawValue === undefined || rawValue === null || rawValue === '')
|
||||
continue
|
||||
|
||||
const computedValue = row[baseCol]
|
||||
if (String(rawValue) === String(computedValue)) continue
|
||||
|
||||
changes.push({ rowIndex, baseCol, originalValue: rawValue })
|
||||
}
|
||||
})
|
||||
|
||||
return changes
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { findOverwrittenCells } from './findOverwrittenCells'
|
||||
|
||||
describe('findOverwrittenCells', () => {
|
||||
it('reports a change when the current value differs from a meaningful raw value', () => {
|
||||
const currentRows = [
|
||||
{ PRIMARY_KEY_FIELD: 1, FORMULA_HARD_COL: 20, FORMULA_SOFT_COL: 12 }
|
||||
]
|
||||
const rawRows = [
|
||||
{ PRIMARY_KEY_FIELD: 1, FORMULA_HARD_COL: 1111, FORMULA_SOFT_COL: 2222 }
|
||||
]
|
||||
|
||||
expect(
|
||||
findOverwrittenCells(
|
||||
currentRows,
|
||||
rawRows,
|
||||
['FORMULA_HARD_COL', 'FORMULA_SOFT_COL'],
|
||||
['PRIMARY_KEY_FIELD']
|
||||
)
|
||||
).toEqual([
|
||||
{ rowIndex: 0, col: 'FORMULA_HARD_COL', originalValue: 1111 },
|
||||
{ rowIndex: 0, col: 'FORMULA_SOFT_COL', originalValue: 2222 }
|
||||
])
|
||||
})
|
||||
|
||||
it('reports nothing when the current value matches the raw value', () => {
|
||||
const currentRows = [{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'unchanged' }]
|
||||
const rawRows = [{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'unchanged' }]
|
||||
|
||||
expect(
|
||||
findOverwrittenCells(
|
||||
currentRows,
|
||||
rawRows,
|
||||
['SOME_CHAR'],
|
||||
['PRIMARY_KEY_FIELD']
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('compares loosely (string vs number) so a "20" raw value matching a 20 current value is not reported', () => {
|
||||
const currentRows = [{ PRIMARY_KEY_FIELD: 1, SOME_NUM: 20 }]
|
||||
const rawRows = [{ PRIMARY_KEY_FIELD: 1, SOME_NUM: '20' }]
|
||||
|
||||
expect(
|
||||
findOverwrittenCells(
|
||||
currentRows,
|
||||
rawRows,
|
||||
['SOME_NUM'],
|
||||
['PRIMARY_KEY_FIELD']
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores a column with no meaningful raw value (blank/null/undefined) - nothing to have changed from', () => {
|
||||
const currentRows = [
|
||||
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'a' },
|
||||
{ PRIMARY_KEY_FIELD: 2, SOME_CHAR: 'b' },
|
||||
{ PRIMARY_KEY_FIELD: 3, SOME_CHAR: 'c' }
|
||||
]
|
||||
const rawRows = [
|
||||
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: '' },
|
||||
{ PRIMARY_KEY_FIELD: 2, SOME_CHAR: null },
|
||||
{ PRIMARY_KEY_FIELD: 3, SOME_CHAR: undefined }
|
||||
]
|
||||
|
||||
expect(
|
||||
findOverwrittenCells(
|
||||
currentRows,
|
||||
rawRows,
|
||||
['SOME_CHAR'],
|
||||
['PRIMARY_KEY_FIELD']
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('only reports the rows/columns that actually changed, across multiple rows', () => {
|
||||
const currentRows = [
|
||||
{ PRIMARY_KEY_FIELD: 1, A: 20, B: 12 },
|
||||
{ PRIMARY_KEY_FIELD: 2, A: 30, B: 13 }
|
||||
]
|
||||
const rawRows = [
|
||||
{ PRIMARY_KEY_FIELD: 1, A: 20, B: 2222 },
|
||||
{ PRIMARY_KEY_FIELD: 2, A: 30, B: 13 }
|
||||
]
|
||||
|
||||
expect(
|
||||
findOverwrittenCells(
|
||||
currentRows,
|
||||
rawRows,
|
||||
['A', 'B'],
|
||||
['PRIMARY_KEY_FIELD']
|
||||
)
|
||||
).toEqual([{ rowIndex: 0, col: 'B', originalValue: 2222 }])
|
||||
})
|
||||
|
||||
it('matches rows by primary key, not array position', () => {
|
||||
const currentRows = [
|
||||
{ PRIMARY_KEY_FIELD: 2, A: 999 },
|
||||
{ PRIMARY_KEY_FIELD: 1, A: 20 }
|
||||
]
|
||||
// rawRows deliberately in a different order than currentRows
|
||||
const rawRows = [
|
||||
{ PRIMARY_KEY_FIELD: 1, A: 20 },
|
||||
{ PRIMARY_KEY_FIELD: 2, A: 30 }
|
||||
]
|
||||
|
||||
expect(
|
||||
findOverwrittenCells(currentRows, rawRows, ['A'], ['PRIMARY_KEY_FIELD'])
|
||||
).toEqual([{ rowIndex: 0, col: 'A', originalValue: 30 }])
|
||||
})
|
||||
|
||||
it('skips a row with no primary-key match in rawRows (e.g. a newly-inserted row)', () => {
|
||||
const currentRows = [
|
||||
{ PRIMARY_KEY_FIELD: 1, A: 20 },
|
||||
{ PRIMARY_KEY_FIELD: undefined, A: 999 }
|
||||
]
|
||||
const rawRows = [{ PRIMARY_KEY_FIELD: 1, A: 1111 }]
|
||||
|
||||
expect(
|
||||
findOverwrittenCells(currentRows, rawRows, ['A'], ['PRIMARY_KEY_FIELD'])
|
||||
).toEqual([{ rowIndex: 0, col: 'A', originalValue: 1111 }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
export interface OverwrittenCell {
|
||||
rowIndex: number
|
||||
col: string
|
||||
originalValue: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds every (row, column) pair where the current value differs from the
|
||||
* real, raw value SAS actually sent for it - i.e. something (a direct edit,
|
||||
* paste, a formula, ...) silently changed a value that pre-existed in the
|
||||
* actual data, not just filled in a blank. Rows are matched by primary key,
|
||||
* not array position, so detection stays correct across row insert/delete/
|
||||
* sort. A row with no PK match in rawRows (a newly-inserted row) is skipped
|
||||
* entirely, since there's no original value to have overwritten. Compared
|
||||
* loosely (via string coercion) since the raw value arrives as whatever
|
||||
* type SAS sent while the current value may be a different but equal
|
||||
* representation (e.g. HyperFormula's own numeric result).
|
||||
*/
|
||||
export const findOverwrittenCells = (
|
||||
currentRows: Record<string, unknown>[],
|
||||
rawRows: Record<string, unknown>[],
|
||||
revertableCols: string[],
|
||||
headerPks: string[]
|
||||
): OverwrittenCell[] => {
|
||||
const changes: OverwrittenCell[] = []
|
||||
|
||||
currentRows.forEach((row, rowIndex) => {
|
||||
const rawRow = rawRows.find((candidate) =>
|
||||
headerPks.every((pk) => candidate[pk] === row[pk])
|
||||
)
|
||||
|
||||
if (!rawRow) return
|
||||
|
||||
for (const col of revertableCols) {
|
||||
const rawValue = rawRow[col]
|
||||
|
||||
if (rawValue === undefined || rawValue === null || rawValue === '') {
|
||||
continue
|
||||
}
|
||||
|
||||
const currentValue = row[col]
|
||||
|
||||
if (String(rawValue) === String(currentValue)) continue
|
||||
|
||||
changes.push({ rowIndex, col, originalValue: rawValue })
|
||||
}
|
||||
})
|
||||
|
||||
return changes
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { DQRule } from '../models/dq-rules.model'
|
||||
import { getRevertableCols } from './getRevertableCols'
|
||||
|
||||
const rule = (overrides: Partial<DQRule>): DQRule => ({
|
||||
BASE_COL: 'SOME_COL',
|
||||
RULE_TYPE: 'SOFTFORMULA',
|
||||
RULE_VALUE: '=A_COL + B_COL',
|
||||
X: 0,
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('getRevertableCols', () => {
|
||||
it('excludes the delete-flag column', () => {
|
||||
expect(
|
||||
getRevertableCols(
|
||||
[],
|
||||
['_____DELETE__THIS__RECORD_____', 'PRIMARY_KEY_FIELD']
|
||||
)
|
||||
).toEqual(['PRIMARY_KEY_FIELD'])
|
||||
})
|
||||
|
||||
it('excludes the delete-flag column even after the editor renames it to its display label', () => {
|
||||
// editor.component.ts renames headerColumns' delete-flag entry to
|
||||
// 'Delete?' in place (for colHeaders display) before this runs, while
|
||||
// the actual Handsontable column data prop stays the raw name - so
|
||||
// both forms must be excluded, or propToCol('Delete?') returns -1.
|
||||
expect(getRevertableCols([], ['Delete?', 'PRIMARY_KEY_FIELD'])).toEqual([
|
||||
'PRIMARY_KEY_FIELD'
|
||||
])
|
||||
})
|
||||
|
||||
it('excludes the hidden dc.row_status column', () => {
|
||||
expect(
|
||||
getRevertableCols([], ['PRIMARY_KEY_FIELD', 'dc.row_status'])
|
||||
).toEqual(['PRIMARY_KEY_FIELD'])
|
||||
})
|
||||
|
||||
it('excludes a DC.*-referencing formula column', () => {
|
||||
expect(
|
||||
getRevertableCols(
|
||||
[
|
||||
rule({
|
||||
BASE_COL: 'CHANGE_SUMMARY_COL',
|
||||
RULE_VALUE: '=DC.ROW_STATUS'
|
||||
})
|
||||
],
|
||||
['PRIMARY_KEY_FIELD', 'CHANGE_SUMMARY_COL']
|
||||
)
|
||||
).toEqual(['PRIMARY_KEY_FIELD'])
|
||||
})
|
||||
|
||||
it('includes a plain column-arithmetic formula column', () => {
|
||||
expect(
|
||||
getRevertableCols(
|
||||
[
|
||||
rule({
|
||||
BASE_COL: 'FORMULA_SOFT_COL',
|
||||
RULE_TYPE: 'SOFTFORMULA',
|
||||
RULE_VALUE: '=A_COL + B_COL'
|
||||
})
|
||||
],
|
||||
['PRIMARY_KEY_FIELD', 'FORMULA_SOFT_COL']
|
||||
)
|
||||
).toEqual(['PRIMARY_KEY_FIELD', 'FORMULA_SOFT_COL'])
|
||||
})
|
||||
|
||||
it('includes an ordinary non-formula column', () => {
|
||||
expect(getRevertableCols([], ['PRIMARY_KEY_FIELD', 'SOME_CHAR'])).toEqual([
|
||||
'PRIMARY_KEY_FIELD',
|
||||
'SOME_CHAR'
|
||||
])
|
||||
})
|
||||
|
||||
it('includes a column governed only by an unrelated rule type (e.g. HARDSELECT)', () => {
|
||||
expect(
|
||||
getRevertableCols(
|
||||
[rule({ BASE_COL: 'SOME_HARDSELECT', RULE_TYPE: 'HARDSELECT' })],
|
||||
['PRIMARY_KEY_FIELD', 'SOME_HARDSELECT']
|
||||
)
|
||||
).toEqual(['PRIMARY_KEY_FIELD', 'SOME_HARDSELECT'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { DQRule } from '../models/dq-rules.model'
|
||||
import { DELETE_RECORD_COLUMN_RULE } from './deleteRecordColumnRule'
|
||||
import { EDIT_STATUS_COLUMN_NAME } from './editStatusColumnRule'
|
||||
import { getStableFormulaBaseCols } from './getStableFormulaBaseCols'
|
||||
|
||||
/**
|
||||
* Display label editor.component.ts renames the delete-flag column's
|
||||
* headerColumns entry to (see initSetup) - the actual Handsontable column
|
||||
* data prop stays DELETE_RECORD_COLUMN_RULE.data, so by the time
|
||||
* headerColumns reaches this function the raw name is already gone from
|
||||
* it and only this label is left to match against.
|
||||
*/
|
||||
const DELETE_RECORD_COLUMN_LABEL = 'Delete?'
|
||||
|
||||
/**
|
||||
* Every column eligible to be checked/marked as "overwritten" (see
|
||||
* findOverwrittenCells) - all of headerColumns except the delete-flag
|
||||
* column, the hidden EDIT_STATUS column, and any HARDFORMULA/SOFTFORMULA
|
||||
* column excluded by getStableFormulaBaseCols (DC.USER_NAME/DC.ORIG_VALUE/
|
||||
* DC.ROW_STATUS-referencing formulas are inherently session-dependent, so
|
||||
* comparing their live result against the raw SAS value is never a
|
||||
* meaningful "was this overwritten" signal).
|
||||
*/
|
||||
export const getRevertableCols = (
|
||||
dqRules: DQRule[],
|
||||
headerColumns: string[]
|
||||
): string[] => {
|
||||
const stableFormulaBaseCols = new Set(getStableFormulaBaseCols(dqRules))
|
||||
const unstableFormulaBaseCols = new Set(
|
||||
dqRules
|
||||
.filter(
|
||||
(rule) =>
|
||||
(rule.RULE_TYPE === 'HARDFORMULA' ||
|
||||
rule.RULE_TYPE === 'SOFTFORMULA') &&
|
||||
!stableFormulaBaseCols.has(rule.BASE_COL)
|
||||
)
|
||||
.map((rule) => rule.BASE_COL)
|
||||
)
|
||||
|
||||
return headerColumns.filter(
|
||||
(col) =>
|
||||
col !== DELETE_RECORD_COLUMN_RULE.data &&
|
||||
col !== DELETE_RECORD_COLUMN_LABEL &&
|
||||
col !== EDIT_STATUS_COLUMN_NAME &&
|
||||
!unstableFormulaBaseCols.has(col)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { syncOverwrittenCellComment } from './syncOverwrittenCellComment'
|
||||
|
||||
describe('syncOverwrittenCellComment', () => {
|
||||
it('sets a comment when the value now differs from raw and none exists yet', () => {
|
||||
expect(syncOverwrittenCellComment('999', 'orig', false)).toBe('set')
|
||||
})
|
||||
|
||||
it('does nothing when the value still differs and a comment is already there', () => {
|
||||
expect(syncOverwrittenCellComment('999', 'orig', true)).toBe('none')
|
||||
})
|
||||
|
||||
it('removes the comment when the value has been typed back to match the raw value', () => {
|
||||
expect(syncOverwrittenCellComment('orig', 'orig', true)).toBe('remove')
|
||||
})
|
||||
|
||||
it('does nothing when the value matches raw and there is no comment', () => {
|
||||
expect(syncOverwrittenCellComment('orig', 'orig', false)).toBe('none')
|
||||
})
|
||||
|
||||
it('treats a blank/null/undefined raw value as never meaningful, even with a stale comment present', () => {
|
||||
expect(syncOverwrittenCellComment('999', '', true)).toBe('remove')
|
||||
expect(syncOverwrittenCellComment('999', null, true)).toBe('remove')
|
||||
expect(syncOverwrittenCellComment('999', undefined, true)).toBe('remove')
|
||||
})
|
||||
|
||||
it('compares loosely (string vs number) so equivalent values are not treated as overwritten', () => {
|
||||
expect(syncOverwrittenCellComment(20, '20', false)).toBe('none')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
export type OverwrittenCommentAction = 'set' | 'remove' | 'none'
|
||||
|
||||
/**
|
||||
* Decides what a single cell's "overwritten" comment should do in response
|
||||
* to a live edit (afterChange) - detection must run on every edit, since an
|
||||
* edit can just as easily make an already-overwritten cell match its raw
|
||||
* value again (typed back by hand) as it can make an untouched cell diverge
|
||||
* from it. Uses the same "loosely equal, blank raw is never meaningful"
|
||||
* rule as findOverwrittenCells, since a single afterChange call operates on
|
||||
* one cell at a time, not the whole-grid batch that function expects.
|
||||
*/
|
||||
export const syncOverwrittenCellComment = (
|
||||
currentValue: unknown,
|
||||
rawValue: unknown,
|
||||
hasCommentAlready: boolean
|
||||
): OverwrittenCommentAction => {
|
||||
const hasMeaningfulRawValue =
|
||||
rawValue !== undefined && rawValue !== null && rawValue !== ''
|
||||
|
||||
const isOverwritten =
|
||||
hasMeaningfulRawValue && String(rawValue) !== String(currentValue)
|
||||
|
||||
if (isOverwritten) return hasCommentAlready ? 'none' : 'set'
|
||||
|
||||
return hasCommentAlready ? 'remove' : 'none'
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { selectFormattedRows } from './select-formatted-rows'
|
||||
|
||||
describe('selectFormattedRows', () => {
|
||||
const rawRows = [{ SOME_NUM: 42, SOME_DATE: 42 }]
|
||||
const formattedRows = [{ SOME_NUM: '42', SOME_DATE: '11FEB1960' }]
|
||||
|
||||
it('returns the formatted rows when showFormatted is true and formatted rows exist', () => {
|
||||
expect(selectFormattedRows(rawRows, formattedRows, true)).toBe(
|
||||
formattedRows
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the raw rows when showFormatted is false, even if formatted rows exist', () => {
|
||||
expect(selectFormattedRows(rawRows, formattedRows, false)).toBe(rawRows)
|
||||
})
|
||||
|
||||
it('falls back to the raw rows when showFormatted is true but no formatted rows were provided', () => {
|
||||
expect(selectFormattedRows(rawRows, undefined, true)).toBe(rawRows)
|
||||
})
|
||||
})
|
||||
@@ -1,13 +0,0 @@
|
||||
/**
|
||||
* Picks which row set to render - raw or SAS-formatted - the same
|
||||
* formatted/unformatted toggle already used on the review page, extracted
|
||||
* so it isn't duplicated inline. Falls back to raw rows if the backend
|
||||
* hasn't sent a formatted variant (e.g. an older service response).
|
||||
*/
|
||||
export function selectFormattedRows(
|
||||
rawRows: any[],
|
||||
formattedRows: any[] | undefined,
|
||||
showFormatted: boolean
|
||||
): any[] {
|
||||
return showFormatted && formattedRows ? formattedRows : rawRows
|
||||
}
|
||||
@@ -66,7 +66,7 @@
|
||||
<div class="card-title text-center">Actions</div>
|
||||
</div>
|
||||
<div class="mt-20">
|
||||
<div class="row stage-actions-row">
|
||||
<div class="row">
|
||||
<button
|
||||
class="btn btn-sm btn-outline text-center mr-5i"
|
||||
(click)="viewerTableScreen()"
|
||||
@@ -105,20 +105,6 @@
|
||||
>
|
||||
<clr-icon shape="download" aria-hidden="true"></clr-icon>
|
||||
</button>
|
||||
<clr-toggle-container class="m-0 flex-shrink-0">
|
||||
<clr-toggle-wrapper>
|
||||
<input
|
||||
type="checkbox"
|
||||
clrToggle
|
||||
checked
|
||||
[(ngModel)]="formattedValues"
|
||||
(change)="formattingChanged()"
|
||||
/>
|
||||
<label class="formatted-values-toggle">{{
|
||||
formattedValues ? 'Formatted' : 'Unformatted'
|
||||
}}</label>
|
||||
</clr-toggle-wrapper>
|
||||
</clr-toggle-container>
|
||||
<clr-tooltip>
|
||||
@if (tableDetails?.['ALLOW_RESTORE'] === 'YES') {
|
||||
<button
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
// Named specifically (not a generic utility) since this component uses
|
||||
// ViewEncapsulation.None - a generic name would leak globally.
|
||||
//
|
||||
// .row here has no CSS establishing it as a flex/grid container (Clarity
|
||||
// doesn't define one) - the buttons lay out via normal inline flow, since
|
||||
// .btn is display: inline-flex (an INLINE outer box). flex-wrap/align-items
|
||||
// would be no-ops on it; vertical-align is what actually governs alignment
|
||||
// between inline-level boxes of different heights.
|
||||
.stage-actions-row {
|
||||
clr-toggle-container {
|
||||
// clr-form-control (Clarity's own class here) sets display: flex, not
|
||||
// inline-flex - a block-level box takes the full width of its
|
||||
// containing block regardless of its own width: auto, which is why it
|
||||
// wrapped onto its own line. inline-flex gives it an inline outer box
|
||||
// (sized to content) so it flows with the buttons instead.
|
||||
display: inline-flex;
|
||||
width: auto;
|
||||
margin-top: 0;
|
||||
// .btn already sets vertical-align: middle - matching it here is what
|
||||
// actually centers this against the buttons, since neither is a flex
|
||||
// item of a shared flex container.
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import { LicenceService } from '../services/licence.service'
|
||||
import { globals } from '../_globals'
|
||||
import { EditorsRestoreServiceResponse } from '../models/sas/editors-restore.model'
|
||||
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
|
||||
import { selectFormattedRows } from '../shared/utils/select-formatted-rows'
|
||||
|
||||
@Component({
|
||||
selector: 'app-stage',
|
||||
@@ -35,9 +34,6 @@ export class StageComponent implements OnInit, AfterViewInit {
|
||||
public tableDetails: any
|
||||
public loaded: boolean = false
|
||||
public revertingChanges: boolean = false
|
||||
public formattedValues: boolean = true
|
||||
private rawStageTable: any[] = []
|
||||
private fmtStageTable: any[] | undefined
|
||||
public licenceState = this.licenceService.licenceState
|
||||
public hotTable: HotTableInterface = {
|
||||
data: [],
|
||||
@@ -122,14 +118,6 @@ export class StageComponent implements OnInit, AfterViewInit {
|
||||
}
|
||||
}
|
||||
|
||||
public formattingChanged() {
|
||||
this.hotTable.data = selectFormattedRows(
|
||||
this.rawStageTable,
|
||||
this.fmtStageTable,
|
||||
this.formattedValues
|
||||
)
|
||||
}
|
||||
|
||||
public download(id: any) {
|
||||
let sasjsConfig = this.sasService.getSasjsConfig()
|
||||
let storage = sasjsConfig.serverUrl
|
||||
@@ -205,14 +193,7 @@ export class StageComponent implements OnInit, AfterViewInit {
|
||||
return cellProperties
|
||||
}
|
||||
|
||||
this.rawStageTable = res.stagetable
|
||||
this.fmtStageTable = res.fmt_stagetable
|
||||
|
||||
this.hotTable.data = selectFormattedRows(
|
||||
this.rawStageTable,
|
||||
this.fmtStageTable,
|
||||
this.formattedValues
|
||||
)
|
||||
this.hotTable.data = res.stagetable
|
||||
this.hotTable.colHeaders = colHeaders
|
||||
this.hotTable.columns = columns
|
||||
this.hotTable.cells = cells
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { StageComponent } from './stage.component'
|
||||
import { HotTableModule } from '@handsontable/angular-wrapper'
|
||||
import { ClarityModule } from '@clr/angular'
|
||||
@@ -12,7 +11,6 @@ const routes: Routes = [{ path: ':tableId', component: StageComponent }]
|
||||
declarations: [StageComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ClarityModule,
|
||||
RouterModule.forChild(routes),
|
||||
HotTableModule
|
||||
|
||||
@@ -4,10 +4,6 @@ _webout=`{"SYSDATE" : "26SEP22"
|
||||
[
|
||||
{"PRIMARY_KEY_FIELD":0 ,"SOME_BESTNUM":44 ,"SOME_CHAR":"this is dummy datass" ,"SOME_DATE":42 ,"SOME_DATETIME":42 ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":42 ,"SOME_SHORTNUM":3 ,"SOME_TIME":42 ,"_____DELETE__THIS__RECORD_____":"No" }
|
||||
]
|
||||
, "fmt_stagetable":
|
||||
[
|
||||
{"PRIMARY_KEY_FIELD":"0" ,"SOME_BESTNUM":"44" ,"SOME_CHAR":"this is dummy datass" ,"SOME_DATE":"12FEB1960" ,"SOME_DATETIME":"01JAN1960:00:00:42" ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":"42" ,"SOME_SHORTNUM":"3" ,"SOME_TIME":"0:00:42" ,"_____DELETE__THIS__RECORD_____":"No" }
|
||||
]
|
||||
,"_DEBUG" : ""
|
||||
,"_METAUSER": "sasdemo@SAS"
|
||||
,"_METAPERSON": "sasdemo"
|
||||
|
||||
@@ -4,10 +4,6 @@ _webout = `{"SYSDATE" : "26SEP22"
|
||||
[
|
||||
{"PRIMARY_KEY_FIELD":0 ,"SOME_BESTNUM":44 ,"SOME_CHAR":"this is changed data" ,"SOME_DATE":42 ,"SOME_DATETIME":42 ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":42 ,"SOME_SHORTNUM":3 ,"SOME_TIME":42 ,"_____DELETE__THIS__RECORD_____":"No" }
|
||||
]
|
||||
, "fmt_stagetable":
|
||||
[
|
||||
{"PRIMARY_KEY_FIELD":"0" ,"SOME_BESTNUM":"44" ,"SOME_CHAR":"this is changed data" ,"SOME_DATE":"12FEB1960" ,"SOME_DATETIME":"01JAN1960:00:00:42" ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":"42" ,"SOME_SHORTNUM":"3" ,"SOME_TIME":"0:00:42" ,"_____DELETE__THIS__RECORD_____":"No" }
|
||||
]
|
||||
,"_DEBUG" : ""
|
||||
,"_METAUSER": "sasdemo@SAS"
|
||||
,"_METAPERSON": "sasdemo"
|
||||
|
||||
@@ -1622,6 +1622,17 @@ let webouts = {
|
||||
DESC: "SOFTFORMULA: combines DC.ROW_STATUS/DC.USER_NAME/DC.ORIG_VALUE - 'unedited' while unchanged, else '<user> changed from <original value>'",
|
||||
LONGDESC: "",
|
||||
COLTYPE: "{\"data\":\"CHANGE_SUMMARY_COL\"}"
|
||||
},
|
||||
{
|
||||
NAME: "PLAIN_TEXT_COL",
|
||||
LABEL: "PLAIN_TEXT_COL",
|
||||
FMTNAME: "",
|
||||
DDTYPE: "C",
|
||||
CLS_RULE: "READ",
|
||||
MEMLABEL: "",
|
||||
DESC: "No DQ rule at all (not a formula column) - a plain character column for manually testing that revert/overwritten-comment also works when nothing but a direct edit (typing, paste, autofill) ever touches it. Its raw value can't be pre-seeded as already overwritten, unlike FORMULA_HARD_COL/FORMULA_SOFT_COL - see dataSourceRaw's own doc comment in editor.component.ts for why only formula columns can do that.",
|
||||
LONGDESC: "",
|
||||
COLTYPE: "{\"data\":\"PLAIN_TEXT_COL\"}"
|
||||
}
|
||||
],
|
||||
dqdata: [],
|
||||
@@ -1639,7 +1650,7 @@ let webouts = {
|
||||
{ ODS_TABLE: "ATTRIBUTES", NAME: "Member Type", VALUE: "DATA" },
|
||||
{ ODS_TABLE: "ATTRIBUTES", NAME: "Engine", VALUE: "V9" },
|
||||
{ ODS_TABLE: "ATTRIBUTES", NAME: "Observations", VALUE: "10" },
|
||||
{ ODS_TABLE: "ATTRIBUTES", NAME: "Variables", VALUE: "9" }
|
||||
{ ODS_TABLE: "ATTRIBUTES", NAME: "Variables", VALUE: "10" }
|
||||
],
|
||||
maxvarlengths: [
|
||||
{ NAME: "_____DELETE__THIS__RECORD_____", MAXLEN: 3 },
|
||||
@@ -1651,7 +1662,8 @@ let webouts = {
|
||||
{ NAME: "row_status_col", MAXLEN: 128 },
|
||||
{ NAME: "user_name_col", MAXLEN: 128 },
|
||||
{ NAME: "orig_value_col", MAXLEN: 128 },
|
||||
{ NAME: "change_summary_col", MAXLEN: 128 }
|
||||
{ NAME: "change_summary_col", MAXLEN: 128 },
|
||||
{ NAME: "plain_text_col", MAXLEN: 128 }
|
||||
],
|
||||
query: [],
|
||||
// 10 rows, well under the editor_rows_allowed=15 cap, with both
|
||||
@@ -1659,21 +1671,33 @@ let webouts = {
|
||||
// and CHANGE_SUMMARY_COL are seeded with a distinctive raw value
|
||||
// (not blank, unlike the other formula columns) so DC.ORIG_VALUE -
|
||||
// which always echoes THIS SAME column's own pre-edit value, never
|
||||
// another column's - has something meaningful to echo back. Row 10
|
||||
// (i===9) additionally seeds FORMULA_HARD_COL/FORMULA_SOFT_COL with
|
||||
// real pre-existing values (1111/2222) that the HARDFORMULA/
|
||||
// SOFTFORMULA rules above overwrite (100/20) - see Cypress test 28.
|
||||
// another column's - has something meaningful to echo back.
|
||||
//
|
||||
// Rows 7-10 (i===6..9) additionally seed FORMULA_HARD_COL/
|
||||
// FORMULA_SOFT_COL with real pre-existing values that the
|
||||
// HARDFORMULA/SOFTFORMULA rules above overwrite on load - a
|
||||
// deliberately staggered pattern (row 7: hard-only, row 8:
|
||||
// soft-only, rows 9-10: both) so a manual/Cypress tester can
|
||||
// right-click a whole ROW (some already-overwritten cells mixed
|
||||
// with untouched ones - e.g. row 7), a whole COLUMN (overwritten
|
||||
// cells scattered across only some of its rows), or a multi-row/
|
||||
// multi-column range and see "Revert" appear/disappear correctly
|
||||
// depending on whether the selection actually contains an
|
||||
// overwritten cell. Rows 1-6 are left untouched so a selection
|
||||
// confined to them proves the negative case (no "Revert" offered).
|
||||
// See Cypress test 28 (row 10) and 32-35 (general revert coverage).
|
||||
sasdata: Array.from({ length: 10 }, (_, i) => ({
|
||||
_____DELETE__THIS__RECORD_____: "No",
|
||||
PRIMARY_KEY_FIELD: i + 1,
|
||||
A_COL: i + 1,
|
||||
B_COL: 10,
|
||||
FORMULA_HARD_COL: i === 9 ? "1111" : "",
|
||||
FORMULA_SOFT_COL: i === 9 ? "2222" : "",
|
||||
FORMULA_HARD_COL: i === 6 ? "7771" : i === 8 ? "9991" : i === 9 ? "1111" : "",
|
||||
FORMULA_SOFT_COL: i === 7 ? "8882" : i === 8 ? "9992" : i === 9 ? "2222" : "",
|
||||
ROW_STATUS_COL: "",
|
||||
USER_NAME_COL: "",
|
||||
ORIG_VALUE_COL: `orig-${i + 1}`,
|
||||
CHANGE_SUMMARY_COL: `orig-${i + 1}`
|
||||
CHANGE_SUMMARY_COL: `orig-${i + 1}`,
|
||||
PLAIN_TEXT_COL: `note-${i + 1}`
|
||||
})),
|
||||
$sasdata: {
|
||||
vars: {
|
||||
@@ -1686,12 +1710,13 @@ let webouts = {
|
||||
ROW_STATUS_COL: { format: "$128.", label: "ROW_STATUS_COL", length: "128", type: "char" },
|
||||
USER_NAME_COL: { format: "$128.", label: "USER_NAME_COL", length: "128", type: "char" },
|
||||
ORIG_VALUE_COL: { format: "$128.", label: "ORIG_VALUE_COL", length: "128", type: "char" },
|
||||
CHANGE_SUMMARY_COL: { format: "$128.", label: "CHANGE_SUMMARY_COL", length: "128", type: "char" }
|
||||
CHANGE_SUMMARY_COL: { format: "$128.", label: "CHANGE_SUMMARY_COL", length: "128", type: "char" },
|
||||
PLAIN_TEXT_COL: { format: "$128.", label: "PLAIN_TEXT_COL", length: "128", type: "char" }
|
||||
}
|
||||
},
|
||||
sasparams: [
|
||||
{
|
||||
COLHEADERS: "_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,A_COL,B_COL,FORMULA_HARD_COL,FORMULA_SOFT_COL,ROW_STATUS_COL,USER_NAME_COL,ORIG_VALUE_COL,CHANGE_SUMMARY_COL",
|
||||
COLHEADERS: "_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,A_COL,B_COL,FORMULA_HARD_COL,FORMULA_SOFT_COL,ROW_STATUS_COL,USER_NAME_COL,ORIG_VALUE_COL,CHANGE_SUMMARY_COL,PLAIN_TEXT_COL",
|
||||
FILTER_TEXT: "",
|
||||
PKCNT: 1,
|
||||
PK: "PRIMARY_KEY_FIELD",
|
||||
|
||||
@@ -29,9 +29,6 @@ run;
|
||||
|
||||
%webout(OPEN)
|
||||
%webout(OBJ,stagetable,missing=STRING)
|
||||
/* same table again with SAS formats applied, for the frontend's
|
||||
Formatted/Unformatted toggle - same pattern as postdata.sas */
|
||||
%webout(OBJ,stagetable,dslabel=fmt_stagetable,fmt=Y,missing=STRING)
|
||||
%webout(CLOSE)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user