Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e516f4012 | ||
|
|
d0a7561f1a | ||
|
|
07d586da52 |
+320
-11
@@ -856,19 +856,19 @@ context('editor tests: ', function () {
|
|||||||
.and('have.class', 'htCommentCell')
|
.and('have.class', 'htCommentCell')
|
||||||
.rightclick({ force: true })
|
.rightclick({ force: true })
|
||||||
|
|
||||||
// Only our own "Revert value" is offered - never the Comments
|
// Only our own "Revert" is offered - never the Comments plugin's
|
||||||
// plugin's own add/edit/delete items, since comments here are
|
// own add/edit/delete items, since comments here are strictly
|
||||||
// strictly programmatic (see the comments: {readOnly: true}
|
// programmatic (see the comments: {readOnly: true} setting and the
|
||||||
// setting and the deliberately curated contextMenu.items list).
|
// deliberately curated contextMenu.items list).
|
||||||
cy.get('.htContextMenu').should(($menu) => {
|
cy.get('.htContextMenu').should(($menu) => {
|
||||||
const text = $menu.text()
|
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('Add comment')
|
||||||
expect(text).not.to.include('Edit comment')
|
expect(text).not.to.include('Edit comment')
|
||||||
expect(text).not.to.include('Delete 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')
|
getCellByHeaderAndRow(9, 'FORMULA_SOFT_COL')
|
||||||
.should('have.text', '2222')
|
.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
|
// 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
|
// discard: the formula recomputes on every load regardless of what's
|
||||||
// sitting in dataSourceUnchanged. Cancelling (without ever touching
|
// sitting in dataSourceUnchanged. Cancelling (without ever touching
|
||||||
// "Revert value") must keep showing the live computed value and the
|
// "Revert") must keep showing the live computed value and the modified
|
||||||
// modified marker, both in the cancelled edit session and back in
|
// marker, both in the cancelled edit session and back in read-only view.
|
||||||
// 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', () => {
|
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')
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
@@ -939,7 +938,7 @@ context('editor tests: ', function () {
|
|||||||
getCellByHeaderAndRow(9, 'FORMULA_SOFT_COL').rightclick({
|
getCellByHeaderAndRow(9, 'FORMULA_SOFT_COL').rightclick({
|
||||||
force: true
|
force: true
|
||||||
})
|
})
|
||||||
cy.get('.htContextMenu').contains('Revert value').click()
|
cy.get('.htContextMenu').contains('Revert').click()
|
||||||
|
|
||||||
getCellByHeaderAndRow(9, 'FORMULA_SOFT_COL')
|
getCellByHeaderAndRow(9, 'FORMULA_SOFT_COL')
|
||||||
.should('have.text', '2222')
|
.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
|
// Handsontable virtualizes columns — with 18 columns on MPE_X_NEW, only
|
||||||
@@ -1017,7 +1326,7 @@ const openColumnDropdown = (headerText: string) => {
|
|||||||
.last()
|
.last()
|
||||||
.as('targetHeader')
|
.as('targetHeader')
|
||||||
|
|
||||||
cy.get('@targetHeader').click()
|
cy.get('@targetHeader').click({ force: true })
|
||||||
cy.get('@targetHeader').find('button.changeType').click({ force: true })
|
cy.get('@targetHeader').find('button.changeType').click({ force: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -170,7 +170,7 @@
|
|||||||
class="card-header clr-row buttonBar headerBar clr-flex-md-row clr-justify-content-center clr-justify-content-lg-end"
|
class="card-header clr-row buttonBar headerBar clr-flex-md-row clr-justify-content-center clr-justify-content-lg-end"
|
||||||
>
|
>
|
||||||
@if (tableTrue && !embed) {
|
@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
|
<span
|
||||||
class="btn icon-collapse btn-sm btn-icon btn-dimmed"
|
class="btn icon-collapse btn-sm btn-icon btn-dimmed"
|
||||||
[routerLink]="['/home']"
|
[routerLink]="['/home']"
|
||||||
@@ -198,7 +198,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div
|
<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"
|
[class.clr-col-lg-12]="!tableTrue"
|
||||||
>
|
>
|
||||||
<h4
|
<h4
|
||||||
@@ -258,12 +258,12 @@
|
|||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
@if (tableTrue) {
|
@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 (hotTable.readOnly && !uploadPreview) {
|
||||||
@if (!isVaEmbed) {
|
@if (!isVaEmbed) {
|
||||||
<button
|
<button
|
||||||
type="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()"
|
(click)="openQb()"
|
||||||
>
|
>
|
||||||
<clr-icon aria-hidden="true" shape="filter"></clr-icon>
|
<clr-icon aria-hidden="true" shape="filter"></clr-icon>
|
||||||
@@ -272,7 +272,7 @@
|
|||||||
}
|
}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn icon-collapse btn-sm btn-primary btn-block"
|
class="btn icon-collapse btn-sm btn-primary"
|
||||||
(click)="editTable()"
|
(click)="editTable()"
|
||||||
>
|
>
|
||||||
<clr-icon aria-hidden="true" shape="note"></clr-icon>
|
<clr-icon aria-hidden="true" shape="note"></clr-icon>
|
||||||
@@ -282,7 +282,7 @@
|
|||||||
<button
|
<button
|
||||||
(click)="onShowUploadModal()"
|
(click)="onShowUploadModal()"
|
||||||
type="button"
|
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>
|
<clr-icon aria-hidden="true" shape="upload"></clr-icon>
|
||||||
<span class="text">Upload</span>
|
<span class="text">Upload</span>
|
||||||
|
|||||||
@@ -41,9 +41,13 @@ import { LoggerService } from '../services/logger.service'
|
|||||||
import { SasService } from '../services/sas.service'
|
import { SasService } from '../services/sas.service'
|
||||||
import { UserService } from '../shared/user.service'
|
import { UserService } from '../shared/user.service'
|
||||||
import { applyFormulaRules } from '../shared/dc-validator/utils/applyFormulaRules'
|
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 { getFormulaCellsToPreserveOnCancel } from '../shared/dc-validator/utils/getFormulaCellsToPreserveOnCancel'
|
||||||
|
import { getRevertableCols } from '../shared/dc-validator/utils/getRevertableCols'
|
||||||
import { getStableFormulaBaseCols } from '../shared/dc-validator/utils/getStableFormulaBaseCols'
|
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 { parseFormulaRule } from '../shared/dc-validator/utils/parseFormulaRule'
|
||||||
import { DcValidator } from '../shared/dc-validator/dc-validator'
|
import { DcValidator } from '../shared/dc-validator/dc-validator'
|
||||||
import { Col } from '../shared/dc-validator/models/col.model'
|
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
|
// Only ever shown when the selection contains at least one cell
|
||||||
// comment to (a HARDFORMULA/SOFTFORMULA cell whose formula
|
// markOverwrittenCells (or the live afterChange sync) attached a
|
||||||
// overwrote a real, pre-existing value) - the comment's presence
|
// comment to - i.e. a cell whose current value differs from what
|
||||||
// is a sufficient and exact signal, no need to separately
|
// SAS actually sent for it, formula-caused or a direct edit. The
|
||||||
// re-check the column against the DQ rules here too.
|
// comment's presence is a sufficient and exact signal, no need to
|
||||||
revert_formula_value: {
|
// separately recompute "is this overwritten" here too. The
|
||||||
name: 'Revert value',
|
// 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) {
|
hidden(this: Handsontable.Core) {
|
||||||
if (this.getSettings().readOnly) return true
|
if (this.getSettings().readOnly) return true
|
||||||
|
|
||||||
const fullCellRange: CellRange[] | undefined =
|
const ranges: CellRange[] | undefined = this.getSelectedRange()
|
||||||
this.getSelectedRange()
|
if (!ranges || ranges.length === 0) return true
|
||||||
if (!fullCellRange) return true
|
|
||||||
|
|
||||||
const { from, to } = fullCellRange[0]
|
|
||||||
if (from.row !== to.row || from.col !== to.col) return true
|
|
||||||
|
|
||||||
const commentsPlugin: any = this.getPlugin('comments')
|
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[]) => {
|
callback: (key: string, selection: any[]) => {
|
||||||
const hot = this.hotInstance
|
const hot = this.hotInstance
|
||||||
const { row, col } = selection[0].start
|
|
||||||
const prop = hot.colToProp(col) as string
|
|
||||||
|
|
||||||
const commentsPlugin: any = hot.getPlugin('comments')
|
const commentsPlugin: any = hot.getPlugin('comments')
|
||||||
const comment: string | undefined =
|
const cells = expandCellRanges(
|
||||||
commentsPlugin.getCommentAtCell(row, col)
|
selection.map((sel) => ({ from: sel.start, to: sel.end })),
|
||||||
if (!comment) return
|
hot.countRows(),
|
||||||
|
hot.countCols()
|
||||||
const rawValueText = comment.slice(
|
|
||||||
EditorComponent.ORIGINAL_VALUE_COMMENT_PREFIX.length
|
|
||||||
)
|
)
|
||||||
const isNumericCol =
|
|
||||||
this.dcValidator?.getRule(prop)?.type === 'numeric'
|
|
||||||
|
|
||||||
hot.setDataAtRowProp(
|
for (const { row, col } of cells) {
|
||||||
row,
|
const comment: string | undefined =
|
||||||
prop,
|
commentsPlugin.getCommentAtCell(row, col)
|
||||||
isNumericCol ? Number(rawValueText) : rawValueText
|
if (!comment) continue
|
||||||
)
|
|
||||||
commentsPlugin.removeCommentAtCell(row, col)
|
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: {
|
row_above: {
|
||||||
@@ -405,9 +423,9 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
* with that cadence.
|
* with that cadence.
|
||||||
*/
|
*/
|
||||||
private static readonly VA_DEBOUNCE_MS = 800
|
private static readonly VA_DEBOUNCE_MS = 800
|
||||||
// Shared between markFormulaChangedCells (writes it) and the
|
// Shared between markOverwrittenCells/syncOverwrittenCommentForCell
|
||||||
// revert_formula_value context menu item (parses it back out) - see
|
// (writes it) and the revert_cells context menu item (parses it back
|
||||||
// findFormulaValueChanges.
|
// out) - see findOverwrittenCells.
|
||||||
private static readonly ORIGINAL_VALUE_COMMENT_PREFIX = 'Original value: '
|
private static readonly ORIGINAL_VALUE_COMMENT_PREFIX = 'Original value: '
|
||||||
public tableTrue: boolean | undefined
|
public tableTrue: boolean | undefined
|
||||||
public saveLoading = false
|
public saveLoading = false
|
||||||
@@ -464,8 +482,8 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
prevDataSource!: any[]
|
prevDataSource!: any[]
|
||||||
dataSourceUnchanged!: any[]
|
dataSourceUnchanged!: any[]
|
||||||
// Raw, as-received-from-SAS snapshot, captured before applyFormulaRules
|
// Raw, as-received-from-SAS snapshot, captured before applyFormulaRules
|
||||||
// overwrites HARDFORMULA/SOFTFORMULA columns - see findFormulaValueChanges
|
// overwrites HARDFORMULA/SOFTFORMULA columns - see findOverwrittenCells
|
||||||
// and getFormulaBaseCols. Distinct from dataSourceUnchanged, which is a
|
// and getRevertableColumnNames. Distinct from dataSourceUnchanged, which is a
|
||||||
// per-editing-session baseline that gets reset on every editTable() call;
|
// per-editing-session baseline that gets reset on every editTable() call;
|
||||||
// this stays fixed for the table's whole lifetime, since "what did the
|
// this stays fixed for the table's whole lifetime, since "what did the
|
||||||
// real dataset actually have before any formula got involved" doesn't
|
// real dataset actually have before any formula got involved" doesn't
|
||||||
@@ -1109,6 +1127,17 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
hot.render()
|
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) {
|
for (const sortConfig of sortConfigs) {
|
||||||
columnSorting.sort(sortConfig)
|
columnSorting.sort(sortConfig)
|
||||||
}
|
}
|
||||||
@@ -1162,7 +1191,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
if (this.dataSourceUnchanged) {
|
if (this.dataSourceUnchanged) {
|
||||||
// dataSourceUnchanged deliberately holds the RAW pre-formula value for
|
// dataSourceUnchanged deliberately holds the RAW pre-formula value for
|
||||||
// a HARDFORMULA/SOFTFORMULA column that still has its
|
// 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
|
// so classifyRow flags the row as modified, but it isn't a session
|
||||||
// edit to discard: the formula recomputes on every load regardless.
|
// edit to discard: the formula recomputes on every load regardless.
|
||||||
// Snapshot those cells' live computed value before the blind restore
|
// Snapshot those cells' live computed value before the blind restore
|
||||||
@@ -1201,6 +1230,14 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
false
|
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 = []
|
this.modifedRowsIndexes = []
|
||||||
hot.validateCells()
|
hot.validateCells()
|
||||||
// this.editRecordListeners();
|
// this.editRecordListeners();
|
||||||
@@ -1497,8 +1534,11 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
* BASE_COL names of every HARDFORMULA/SOFTFORMULA rule whose result is
|
* BASE_COL names of every HARDFORMULA/SOFTFORMULA rule whose result is
|
||||||
* expected to be *stable* for a given row (see getStableFormulaBaseCols
|
* expected to be *stable* for a given row (see getStableFormulaBaseCols
|
||||||
* for why DC.USER_NAME/DC.ORIG_VALUE/DC.ROW_STATUS-based rules are
|
* for why DC.USER_NAME/DC.ORIG_VALUE/DC.ROW_STATUS-based rules are
|
||||||
* excluded). Used by markFormulaChangedCells and the dataSourceUnchanged
|
* excluded). Used only by overlayFormulaRawValuesOnUnchanged and
|
||||||
* overlay in editTable() - NOT the same filter seedFormulaValuesForRow
|
* 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
|
* uses above, which seeds every formula column regardless of this
|
||||||
* distinction.
|
* distinction.
|
||||||
*/
|
*/
|
||||||
@@ -1512,7 +1552,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
/**
|
/**
|
||||||
* Overlays the true raw (pre-formula) value onto dataSourceUnchanged for
|
* Overlays the true raw (pre-formula) value onto dataSourceUnchanged for
|
||||||
* every HARDFORMULA/SOFTFORMULA base col that already had real data (see
|
* 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
|
* 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
|
* dataset - that's a real change classifyRow's diff should pick up, not
|
||||||
* something that only becomes visible once an edit session starts. Shared
|
* 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
|
* Every column eligible to be checked/marked as "overwritten" - every
|
||||||
* a value that already existed in the real dataset (see
|
* revertable column (see getRevertableCols), not just the narrower
|
||||||
* findFormulaValueChanges) with a read-only comment showing the original
|
* formula-only set getFormulaBaseCols() returns. Used by
|
||||||
* value - otherwise there's no visual difference between "the formula
|
* markOverwrittenCells and the live afterChange sync - NOT by
|
||||||
* just filled in a blank" and "the formula overwrote real data no one
|
* overlayFormulaRawValuesOnUnchanged/cancelEdit's preserve-on-cancel
|
||||||
* asked to change". Must run after hot.updateSettings() has processed the
|
* patch, which stay scoped to getFormulaBaseCols() specifically (see
|
||||||
* formulas: getDataAtRowProp only resolves the live computed value once
|
* those methods' own doc comments for why).
|
||||||
* HyperFormula has actually evaluated the cell, not the raw formula
|
*/
|
||||||
* string dataSource holds right after applyFormulaRules.
|
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,
|
* Also flips each affected row's EDIT_STATUS to 'M' - this is a real,
|
||||||
* permanent difference from the raw dataset (every future load recomputes
|
* permanent difference from the raw dataset (every future load
|
||||||
* the same formula again), not a session edit afterChange would ever see,
|
* recomputes the same formula again, and a direct edit made before this
|
||||||
* so nothing else would otherwise mark these rows modified. Doing this
|
* method ever ran isn't something afterChange would have seen), so
|
||||||
* once here, before dataSourceUnchanged is ever cloned from dataSource,
|
* nothing else would otherwise mark these rows modified. Doing this once
|
||||||
* means the 'M' baseline is already shared by both snapshots - no special
|
* here, before dataSourceUnchanged is ever cloned from dataSource, means
|
||||||
* handling needed to keep it from being wiped out by a later cancelEdit().
|
* 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 hot = this.hotInstance
|
||||||
const formulaBaseCols = this.getFormulaBaseCols()
|
const revertableCols = this.getRevertableColumnNames()
|
||||||
if (formulaBaseCols.length === 0 || !this.dataSourceRaw) return
|
if (revertableCols.length === 0 || !this.dataSourceRaw) return
|
||||||
|
|
||||||
const computedRows = this.dataSource.map((_row, rowIndex) =>
|
const currentRows = this.dataSource.map((row, rowIndex) => ({
|
||||||
Object.fromEntries(
|
...row,
|
||||||
formulaBaseCols.map((baseCol) => [
|
...Object.fromEntries(
|
||||||
baseCol,
|
revertableCols.map((col) => [col, hot.getDataAtRowProp(rowIndex, col)])
|
||||||
hot.getDataAtRowProp(rowIndex, baseCol)
|
|
||||||
])
|
|
||||||
)
|
)
|
||||||
)
|
}))
|
||||||
|
|
||||||
const changes = findFormulaValueChanges(
|
const changes = findOverwrittenCells(
|
||||||
computedRows,
|
currentRows,
|
||||||
this.dataSourceRaw,
|
this.dataSourceRaw,
|
||||||
formulaBaseCols
|
revertableCols,
|
||||||
|
this.headerPks
|
||||||
)
|
)
|
||||||
|
const changedRows = new Set(changes.map((change) => change.rowIndex))
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not classifyRow/updateEditStatusForRow - dataSourceUnchanged doesn't
|
// Not classifyRow/updateEditStatusForRow - dataSourceUnchanged doesn't
|
||||||
// exist yet this early (only editTable() sets it), and these rows are
|
// 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.
|
// reclassify.
|
||||||
//
|
//
|
||||||
// Deferred: setDataAtRowProp needs the Formulas plugin's hidden-column
|
// 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
|
this.$dataFormats = response.data.$sasdata
|
||||||
|
|
||||||
// Raw, as-received snapshot - captured before applyFormulaRules below
|
// 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
|
// can later tell "the formula filled in a blank" apart from "the
|
||||||
// formula overwrote a value the real dataset already had".
|
// formula overwrote a value the real dataset already had".
|
||||||
this.dataSourceRaw = this.helperService.deepClone(this.dataSource)
|
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
|
// Seeded here too (not just editTable()) so a HARDFORMULA/SOFTFORMULA
|
||||||
// rule that silently overwrote real pre-existing data (see
|
// 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
|
// '~' row header and EDIT_STATUS='M' - on the very first render, before
|
||||||
// the user ever clicks Edit. editTable() rebuilds this fresh every time
|
// the user ever clicks Edit. editTable() rebuilds this fresh every time
|
||||||
// it runs regardless, so setting it here doesn't affect that.
|
// 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.
|
// collision-proof dotted name.
|
||||||
dataDotNotation: false,
|
dataDotNotation: false,
|
||||||
// readOnly here means comments can never be added/edited/removed
|
// readOnly here means comments can never be added/edited/removed
|
||||||
// through the UI - only markFormulaChangedCells (via the plugin
|
// through the UI - only markOverwrittenCells/
|
||||||
// API) ever sets one. The context menu below only offers our own
|
// syncOverwrittenCommentForCell (via the plugin API) ever set one.
|
||||||
// "Revert value" item, never the plugin's own add/edit/remove ones.
|
// The context menu below only offers our own "Revert" item, never
|
||||||
|
// the plugin's own add/edit/remove ones.
|
||||||
comments: { readOnly: true },
|
comments: { readOnly: true },
|
||||||
stretchH: 'all',
|
stretchH: 'all',
|
||||||
readOnly: this.hotTable.readOnly,
|
readOnly: this.hotTable.readOnly,
|
||||||
@@ -3650,6 +3761,12 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
},
|
},
|
||||||
info: {
|
info: {
|
||||||
name: 'test 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: (
|
renderer: (
|
||||||
hot: Handsontable.Core,
|
hot: Handsontable.Core,
|
||||||
wrapper: HTMLElement,
|
wrapper: HTMLElement,
|
||||||
@@ -3689,6 +3806,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
elem.innerHTML = textInfo
|
elem.innerHTML = textInfo
|
||||||
|
preventMenuItemAutoClose(elem)
|
||||||
|
|
||||||
return elem
|
return elem
|
||||||
}
|
}
|
||||||
@@ -3778,7 +3896,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
// resolves formulas' live computed values once HyperFormula has
|
// resolves formulas' live computed values once HyperFormula has
|
||||||
// actually evaluated them against the data/formulas settings just
|
// actually evaluated them against the data/formulas settings just
|
||||||
// applied.
|
// applied.
|
||||||
this.markFormulaChangedCells()
|
this.markOverwrittenCells()
|
||||||
|
|
||||||
this.hotTable.hidden = false
|
this.hotTable.hidden = false
|
||||||
// Keep the context menu enabled in view mode too so Copy/Export remain
|
// 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
|
// (initial load, cancelSubmit, ...) where dataSource is already
|
||||||
// consistent, so it's skipped; 'editStatus' is this same hook's own
|
// consistent, so it's skipped; 'editStatus' is this same hook's own
|
||||||
// writes (via updateEditStatusForRow), skipped to avoid recursion.
|
// 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) => {
|
hot.addHook('afterChange', (changes: any[], source: any) => {
|
||||||
if (!changes || source === 'loadData' || source === 'editStatus') return
|
if (!changes || source === 'loadData' || source === 'editStatus') return
|
||||||
|
|
||||||
|
const revertableCols = this.getRevertableColumnNames()
|
||||||
const changedRows = new Set<number>()
|
const changedRows = new Set<number>()
|
||||||
for (const change of changes) {
|
for (const change of changes) {
|
||||||
if (!change) continue
|
if (!change) continue
|
||||||
@@ -3923,6 +4048,10 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
if (prop === EDIT_STATUS_COLUMN_NAME) continue
|
if (prop === EDIT_STATUS_COLUMN_NAME) continue
|
||||||
|
|
||||||
changedRows.add(row)
|
changedRows.add(row)
|
||||||
|
|
||||||
|
if (revertableCols.includes(prop)) {
|
||||||
|
this.syncOverwrittenCommentForCell(row, prop)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const row of changedRows) this.updateEditStatusForRow(row)
|
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'
|
||||||
|
}
|
||||||
@@ -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>'",
|
DESC: "SOFTFORMULA: combines DC.ROW_STATUS/DC.USER_NAME/DC.ORIG_VALUE - 'unedited' while unchanged, else '<user> changed from <original value>'",
|
||||||
LONGDESC: "",
|
LONGDESC: "",
|
||||||
COLTYPE: "{\"data\":\"CHANGE_SUMMARY_COL\"}"
|
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: [],
|
dqdata: [],
|
||||||
@@ -1639,7 +1650,7 @@ let webouts = {
|
|||||||
{ ODS_TABLE: "ATTRIBUTES", NAME: "Member Type", VALUE: "DATA" },
|
{ ODS_TABLE: "ATTRIBUTES", NAME: "Member Type", VALUE: "DATA" },
|
||||||
{ ODS_TABLE: "ATTRIBUTES", NAME: "Engine", VALUE: "V9" },
|
{ ODS_TABLE: "ATTRIBUTES", NAME: "Engine", VALUE: "V9" },
|
||||||
{ ODS_TABLE: "ATTRIBUTES", NAME: "Observations", VALUE: "10" },
|
{ ODS_TABLE: "ATTRIBUTES", NAME: "Observations", VALUE: "10" },
|
||||||
{ ODS_TABLE: "ATTRIBUTES", NAME: "Variables", VALUE: "9" }
|
{ ODS_TABLE: "ATTRIBUTES", NAME: "Variables", VALUE: "10" }
|
||||||
],
|
],
|
||||||
maxvarlengths: [
|
maxvarlengths: [
|
||||||
{ NAME: "_____DELETE__THIS__RECORD_____", MAXLEN: 3 },
|
{ NAME: "_____DELETE__THIS__RECORD_____", MAXLEN: 3 },
|
||||||
@@ -1651,7 +1662,8 @@ let webouts = {
|
|||||||
{ NAME: "row_status_col", MAXLEN: 128 },
|
{ NAME: "row_status_col", MAXLEN: 128 },
|
||||||
{ NAME: "user_name_col", MAXLEN: 128 },
|
{ NAME: "user_name_col", MAXLEN: 128 },
|
||||||
{ NAME: "orig_value_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: [],
|
query: [],
|
||||||
// 10 rows, well under the editor_rows_allowed=15 cap, with both
|
// 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
|
// and CHANGE_SUMMARY_COL are seeded with a distinctive raw value
|
||||||
// (not blank, unlike the other formula columns) so DC.ORIG_VALUE -
|
// (not blank, unlike the other formula columns) so DC.ORIG_VALUE -
|
||||||
// which always echoes THIS SAME column's own pre-edit value, never
|
// which always echoes THIS SAME column's own pre-edit value, never
|
||||||
// another column's - has something meaningful to echo back. Row 10
|
// another column's - has something meaningful to echo back.
|
||||||
// (i===9) additionally seeds FORMULA_HARD_COL/FORMULA_SOFT_COL with
|
//
|
||||||
// real pre-existing values (1111/2222) that the HARDFORMULA/
|
// Rows 7-10 (i===6..9) additionally seed FORMULA_HARD_COL/
|
||||||
// SOFTFORMULA rules above overwrite (100/20) - see Cypress test 28.
|
// 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) => ({
|
sasdata: Array.from({ length: 10 }, (_, i) => ({
|
||||||
_____DELETE__THIS__RECORD_____: "No",
|
_____DELETE__THIS__RECORD_____: "No",
|
||||||
PRIMARY_KEY_FIELD: i + 1,
|
PRIMARY_KEY_FIELD: i + 1,
|
||||||
A_COL: i + 1,
|
A_COL: i + 1,
|
||||||
B_COL: 10,
|
B_COL: 10,
|
||||||
FORMULA_HARD_COL: i === 9 ? "1111" : "",
|
FORMULA_HARD_COL: i === 6 ? "7771" : i === 8 ? "9991" : i === 9 ? "1111" : "",
|
||||||
FORMULA_SOFT_COL: i === 9 ? "2222" : "",
|
FORMULA_SOFT_COL: i === 7 ? "8882" : i === 8 ? "9992" : i === 9 ? "2222" : "",
|
||||||
ROW_STATUS_COL: "",
|
ROW_STATUS_COL: "",
|
||||||
USER_NAME_COL: "",
|
USER_NAME_COL: "",
|
||||||
ORIG_VALUE_COL: `orig-${i + 1}`,
|
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: {
|
$sasdata: {
|
||||||
vars: {
|
vars: {
|
||||||
@@ -1686,12 +1710,13 @@ let webouts = {
|
|||||||
ROW_STATUS_COL: { format: "$128.", label: "ROW_STATUS_COL", length: "128", type: "char" },
|
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" },
|
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" },
|
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: [
|
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: "",
|
FILTER_TEXT: "",
|
||||||
PKCNT: 1,
|
PKCNT: 1,
|
||||||
PK: "PRIMARY_KEY_FIELD",
|
PK: "PRIMARY_KEY_FIELD",
|
||||||
|
|||||||
Reference in New Issue
Block a user