Merge branch 'version7-13' into issue-148
This commit is contained in:
@@ -642,13 +642,13 @@ context('editor tests: ', function () {
|
||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then(() => {
|
||||
// _____EDIT_STATUS_____ itself is a purely client-synthesized
|
||||
// column (see editStatusColumnRule.ts) - it must never render as a
|
||||
// visible header, only exist as something DC.ROW_STATUS can
|
||||
// point a cell reference at.
|
||||
// dc.row_status itself is a purely client-synthesized column (see
|
||||
// editStatusColumnRule.ts) - it must never render as a visible
|
||||
// header, only exist as something DC.ROW_STATUS can point a cell
|
||||
// reference at.
|
||||
cy.get('.ht_clone_top .htCore thead tr th').should(($ths) => {
|
||||
const texts = [...$ths].map((th) => th.innerText.trim())
|
||||
expect(texts).not.to.include('_____EDIT_STATUS_____')
|
||||
expect(texts).not.to.include('dc.row_status')
|
||||
})
|
||||
|
||||
getCellByHeaderAndRow(0, 'ROW_STATUS_COL').should('have.text', 'U')
|
||||
@@ -768,6 +768,212 @@ context('editor tests: ', function () {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// dataSource keeps the raw '=...' formula string for a SOFTFORMULA cell -
|
||||
// HyperFormula recalculates the displayed value live without ever
|
||||
// mutating that underlying string, so the submit payload must be built
|
||||
// from the live computed value, not from dataSource directly. Editing
|
||||
// A_COL (not FORMULA_SOFT_COL itself) both marks the row as submittable
|
||||
// and proves the live-recalculated value - not a stale one - is what
|
||||
// gets sent.
|
||||
it('27 | Submits the computed formula value, not the raw formula string', () => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||
|
||||
clickOnEdit(() => {
|
||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then(() => {
|
||||
cy.intercept('POST', '**/SASjsApi/stp/execute*', (req) => {
|
||||
const bodyText = JSON.stringify(req.body || '')
|
||||
|
||||
if (bodyText.includes('stagedata')) {
|
||||
expect(bodyText).to.include('"FORMULA_SOFT_COL":15')
|
||||
expect(bodyText).not.to.match(/"FORMULA_SOFT_COL":"?=/)
|
||||
}
|
||||
|
||||
req.continue()
|
||||
}).as('stpExecute')
|
||||
|
||||
// Row 0: PRIMARY_KEY_FIELD=1, B_COL=10. Changing A_COL from 1 to 5
|
||||
// marks the row modified (so it's actually submitted) and live-
|
||||
// recalculates FORMULA_SOFT_COL (A_COL + B_COL) from 11 to 15,
|
||||
// without the user ever directly editing that cell.
|
||||
getCellByHeaderAndRow(0, 'A_COL')
|
||||
.dblclick({ force: true })
|
||||
.then(() => {
|
||||
cy.focused().clear().type('5{enter}')
|
||||
})
|
||||
|
||||
getCellByHeaderAndRow(0, 'FORMULA_SOFT_COL').should('have.text', '15')
|
||||
|
||||
submitTable()
|
||||
|
||||
cy.get('#submitBtn', { timeout: longerCommandTimeout })
|
||||
.should('exist')
|
||||
.should('not.be.disabled')
|
||||
|
||||
cy.get('#formFields_8').type('formula value submission test')
|
||||
|
||||
submitTableMessage()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Row 9 (0-indexed, PRIMARY_KEY_FIELD=10): the mock seeds
|
||||
// FORMULA_HARD_COL/FORMULA_SOFT_COL with real pre-existing values
|
||||
// (1111/2222) that the computed formula (A_COL*B_COL=100,
|
||||
// A_COL+B_COL=20 for this row) silently overwrites at load - simulating
|
||||
// a formula rule added to a column that already had real data. Every
|
||||
// other row's formula columns are seeded blank, so only this row is
|
||||
// affected.
|
||||
it('28 | A formula overwriting real pre-existing data marks the row modified, adds a comment, and can be reverted', () => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||
|
||||
// Already true on the very first render, before Edit is ever clicked -
|
||||
// dataSourceUnchanged is seeded with the formula overlay at load time
|
||||
// too, not just inside editTable().
|
||||
getRowHeaderSymbol(9).should('have.text', '~')
|
||||
getCellByHeaderAndRow(9, 'ROW_STATUS_COL').should('have.text', 'M')
|
||||
|
||||
clickOnEdit(() => {
|
||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then(() => {
|
||||
getRowHeaderSymbol(9).should('have.text', '~')
|
||||
|
||||
// The silent overwrite is a real, permanent difference from the raw
|
||||
// dataset (not a session edit afterChange would ever see), so
|
||||
// DC.ROW_STATUS must already read 'M' here too, not just the row
|
||||
// header symbol.
|
||||
getCellByHeaderAndRow(9, 'ROW_STATUS_COL').should('have.text', 'M')
|
||||
|
||||
getCellByHeaderAndRow(9, 'FORMULA_HARD_COL')
|
||||
.should('have.text', '100')
|
||||
.and('have.class', 'htCommentCell')
|
||||
|
||||
getCellByHeaderAndRow(9, 'FORMULA_SOFT_COL')
|
||||
.should('have.text', '20')
|
||||
.and('have.class', 'htCommentCell')
|
||||
.rightclick({ force: true })
|
||||
|
||||
// Only our own "Revert 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).
|
||||
cy.get('.htContextMenu').should(($menu) => {
|
||||
const text = $menu.text()
|
||||
expect(text).to.include('Revert value')
|
||||
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()
|
||||
|
||||
getCellByHeaderAndRow(9, 'FORMULA_SOFT_COL')
|
||||
.should('have.text', '2222')
|
||||
.and('not.have.class', 'htCommentCell')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// dataSourceUnchanged deliberately holds the RAW pre-formula value for
|
||||
// FORMULA_HARD_COL/FORMULA_SOFT_COL so classifyRow flags row 9 as
|
||||
// modified (see test 28) - but that's not a session edit for Cancel to
|
||||
// discard: the formula recomputes on every load regardless of what's
|
||||
// sitting in dataSourceUnchanged. Cancelling (without ever touching
|
||||
// "Revert value") must keep showing the live computed value and the
|
||||
// modified marker, both in the cancelled edit session and back in
|
||||
// read-only view.
|
||||
it('29 | Cancelling an edit session after a formula silently overwrote real data keeps the computed value and modified marker, not the stale raw one', () => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||
|
||||
clickOnEdit(() => {
|
||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then(() => {
|
||||
getCellByHeaderAndRow(9, 'FORMULA_HARD_COL').should('have.text', '100')
|
||||
getCellByHeaderAndRow(9, 'FORMULA_SOFT_COL').should('have.text', '20')
|
||||
|
||||
cy.contains('button', 'Cancel').click()
|
||||
|
||||
getRowHeaderSymbol(9).should('have.text', '~')
|
||||
getCellByHeaderAndRow(9, 'ROW_STATUS_COL').should('have.text', 'M')
|
||||
getCellByHeaderAndRow(9, 'FORMULA_HARD_COL').should('have.text', '100')
|
||||
getCellByHeaderAndRow(9, 'FORMULA_SOFT_COL').should('have.text', '20')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// CHANGE_SUMMARY_COL's own raw seed ('orig-1') genuinely differs from its
|
||||
// computed result ('unedited') for row 0, purely because its formula
|
||||
// references DC.ROW_STATUS/DC.ORIG_VALUE, which are inherently
|
||||
// session-dependent - not because anything overwrote real data. See
|
||||
// getStableFormulaBaseCols, which excludes any DC.*-referencing formula
|
||||
// rule from the "did a formula overwrite real data" comparison entirely.
|
||||
it('30 | DC.*-referencing formula columns never get a "changed value" comment, even when their own raw seed differs from the computed result', () => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||
|
||||
clickOnEdit(() => {
|
||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then(() => {
|
||||
getCellByHeaderAndRow(0, 'ORIG_VALUE_COL')
|
||||
.should('have.text', 'orig-1')
|
||||
.and('not.have.class', 'htCommentCell')
|
||||
|
||||
getCellByHeaderAndRow(0, 'CHANGE_SUMMARY_COL')
|
||||
.should('have.text', 'unedited')
|
||||
.and('not.have.class', 'htCommentCell')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('31 | Reverting a formula value then cancelling keeps it plain, and it stays plain on the next edit session', () => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||
|
||||
clickOnEdit(() => {
|
||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then(() => {
|
||||
getCellByHeaderAndRow(9, 'FORMULA_SOFT_COL').rightclick({
|
||||
force: true
|
||||
})
|
||||
cy.get('.htContextMenu').contains('Revert value').click()
|
||||
|
||||
getCellByHeaderAndRow(9, 'FORMULA_SOFT_COL')
|
||||
.should('have.text', '2222')
|
||||
.and('not.have.class', 'htCommentCell')
|
||||
|
||||
cy.contains('button', 'Cancel').click()
|
||||
})
|
||||
})
|
||||
|
||||
// Back in view mode: the revert is a real, permanent change - not a
|
||||
// session edit for Cancel to discard (see
|
||||
// getFormulaCellsToPreserveOnCancel - a reverted cell has no comment,
|
||||
// so it's excluded from the preserve-the-computed-value patch, and
|
||||
// dataSourceUnchanged's raw value, which now matches, is left as-is).
|
||||
// FORMULA_HARD_COL was never reverted, so it still shows the live
|
||||
// computed value and keeps the row modified via that column alone.
|
||||
getCellByHeaderAndRow(9, 'FORMULA_SOFT_COL')
|
||||
.should('have.text', '2222')
|
||||
.and('not.have.class', 'htCommentCell')
|
||||
getCellByHeaderAndRow(9, 'FORMULA_HARD_COL').should('have.text', '100')
|
||||
getRowHeaderSymbol(9).should('have.text', '~')
|
||||
|
||||
// "Forever" per the original requirement - re-entering edit mode must
|
||||
// not resurrect the formula for the reverted cell.
|
||||
clickOnEdit(() => {
|
||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then(() => {
|
||||
getCellByHeaderAndRow(9, 'FORMULA_SOFT_COL')
|
||||
.should('have.text', '2222')
|
||||
.and('not.have.class', 'htCommentCell')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Handsontable virtualizes columns — with 18 columns on MPE_X_NEW, only
|
||||
|
||||
Generated
+82
-82
@@ -7,15 +7,15 @@
|
||||
"name": "data_controller-client",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^20.3.26",
|
||||
"@angular/animations": "^20.3.27",
|
||||
"@angular/cdk": "^20.2.14",
|
||||
"@angular/common": "^20.3.26",
|
||||
"@angular/compiler": "^20.3.26",
|
||||
"@angular/core": "^20.3.26",
|
||||
"@angular/forms": "^20.3.26",
|
||||
"@angular/platform-browser": "^20.3.26",
|
||||
"@angular/platform-browser-dynamic": "^20.3.26",
|
||||
"@angular/router": "^20.3.26",
|
||||
"@angular/common": "^20.3.27",
|
||||
"@angular/compiler": "^20.3.27",
|
||||
"@angular/core": "^20.3.27",
|
||||
"@angular/forms": "^20.3.27",
|
||||
"@angular/platform-browser": "^20.3.27",
|
||||
"@angular/platform-browser-dynamic": "^20.3.27",
|
||||
"@angular/router": "^20.3.27",
|
||||
"@cds/core": "^6.15.1",
|
||||
"@clr/angular": "file:libraries/clr-angular-17.9.0.tgz",
|
||||
"@clr/icons": "^13.0.2",
|
||||
@@ -66,7 +66,7 @@
|
||||
"@angular-eslint/schematics": "19.8.1",
|
||||
"@angular-eslint/template-parser": "19.8.1",
|
||||
"@angular/cli": "^20.3.32",
|
||||
"@angular/compiler-cli": "^20.3.26",
|
||||
"@angular/compiler-cli": "^20.3.27",
|
||||
"@babel/plugin-proposal-private-methods": "^7.18.6",
|
||||
"@compodoc/compodoc": "^2.0.0",
|
||||
"@cypress/webpack-preprocessor": "^5.17.1",
|
||||
@@ -1469,9 +1469,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/animations": {
|
||||
"version": "20.3.26",
|
||||
"resolved": "https://registry.npmjs.org/@angular/animations/-/animations-20.3.26.tgz",
|
||||
"integrity": "sha512-hfNrX19v8xs/usNkELSqc6q5IwBfS2GGW8sQ4OMpxAmLZDJwaLUxkj48t8VYhUFezsIfzR+sDEi6ZQBOaMIYug==",
|
||||
"version": "20.3.27",
|
||||
"resolved": "https://registry.npmjs.org/@angular/animations/-/animations-20.3.27.tgz",
|
||||
"integrity": "sha512-BgGTloDiD3qIFVSxZq8xO6CiyhKn00WbhQQiklZF8WI2hXd3Hmc1OUAAHqSMh2c9uL7X1ZYkg9lSzjiasK2vKg==",
|
||||
"deprecated": "@angular/animations is deprecated. Use `animate.enter` and `animate.leave` instead. For more information see: https://v22.angular.dev/guide/animations.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1481,7 +1481,7 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/core": "20.3.26"
|
||||
"@angular/core": "20.3.27"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/cdk": {
|
||||
@@ -1973,9 +1973,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/common": {
|
||||
"version": "20.3.26",
|
||||
"resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.26.tgz",
|
||||
"integrity": "sha512-35+aHaCmldFZ2qFiH83+cHcDXwUqSEuUR5DVApcd+Ku8PfIIGo8uMiD5++Qq7QIUTbZCD2glAiE9jLroGrf1Cw==",
|
||||
"version": "20.3.27",
|
||||
"resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.27.tgz",
|
||||
"integrity": "sha512-4ectYP60XatB9zZ40WlfmaTzjmEhaz8SSqLsbZI4VZ8gDb5qNmxWtwwt8UxS3NmDHEgqdNL8UPO4E94+yKCICg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
@@ -1984,14 +1984,14 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/core": "20.3.26",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
"rxjs": "^6.5.3 || ^7.4.0",
|
||||
"@angular/core": "20.3.27"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/compiler": {
|
||||
"version": "20.3.26",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.26.tgz",
|
||||
"integrity": "sha512-H4DVTBCiyM4dGytFi2C8sMGflxXzPnoQ6Ajfs4hJ/Dekg6ypfvW5Ze7BDh4TaMQvaX2joM5LhBYW5jTxBx66hA==",
|
||||
"version": "20.3.27",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.27.tgz",
|
||||
"integrity": "sha512-in3THZ678GAYuOR9RZV18+zZz0KGhlGikyUEfLeALLjGf9ZaR3n+t19BmYx6G2VkF/Xqadne1omQ2vbl6PRASA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
@@ -2001,31 +2001,31 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/compiler-cli": {
|
||||
"version": "20.3.26",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-20.3.26.tgz",
|
||||
"integrity": "sha512-3rHtC87ecldvaiFHwQEZ6Wx3QaZ/Q7b0Gb7XORDOjn/M+5CYZ4rQsvbxE5TUjwreg07oQ4Y2h8ADESXTJEUYOQ==",
|
||||
"version": "20.3.27",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-20.3.27.tgz",
|
||||
"integrity": "sha512-R0j9mFfUdGmmw867V/TfMSOBkLZT6ASxyY5tc1NDNmxQioZDVIDP9pqBOayzhJ0xiuDc9JellQXUTZ+vm+b/Zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/core": "7.29.7",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14",
|
||||
"chokidar": "^4.0.0",
|
||||
"convert-source-map": "^1.5.1",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"semver": "^7.0.0",
|
||||
"tslib": "^2.3.0",
|
||||
"yargs": "^18.0.0"
|
||||
"yargs": "^18.0.0",
|
||||
"semver": "^7.0.0",
|
||||
"chokidar": "^4.0.0",
|
||||
"@babel/core": "7.29.7",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"convert-source-map": "^1.5.1",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
},
|
||||
"bin": {
|
||||
"ng-xi18n": "bundles/src/bin/ng_xi18n.js",
|
||||
"ngc": "bundles/src/bin/ngc.js"
|
||||
"ngc": "bundles/src/bin/ngc.js",
|
||||
"ng-xi18n": "bundles/src/bin/ng_xi18n.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/compiler": "20.3.26",
|
||||
"typescript": ">=5.8 <6.0"
|
||||
"typescript": ">=5.8 <6.0",
|
||||
"@angular/compiler": "20.3.27"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
@@ -2034,9 +2034,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/core": {
|
||||
"version": "20.3.26",
|
||||
"resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.26.tgz",
|
||||
"integrity": "sha512-v+YtZ9eQVDb6v3V1TbUUBHU63FEp8Hqqqb3UhM4MLAOm0chyyh9jah7FiHr3HbCKrV1f4long1coftFK/KThog==",
|
||||
"version": "20.3.27",
|
||||
"resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.27.tgz",
|
||||
"integrity": "sha512-8EfYIUST5CKldOF4MAYWTFFRB7EtwqUoQBZdar6US39/EEzWm/wm/iRNkH2jmKe4YuPa2GoeHS1WQ8VRuOk7Dg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
@@ -2045,23 +2045,23 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/compiler": "20.3.26",
|
||||
"rxjs": "^6.5.3 || ^7.4.0",
|
||||
"zone.js": "~0.15.0"
|
||||
"zone.js": "~0.15.0",
|
||||
"@angular/compiler": "20.3.27"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@angular/compiler": {
|
||||
"zone.js": {
|
||||
"optional": true
|
||||
},
|
||||
"zone.js": {
|
||||
"@angular/compiler": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/forms": {
|
||||
"version": "20.3.26",
|
||||
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-20.3.26.tgz",
|
||||
"integrity": "sha512-ia0YaPVjlG2oBFKCfaAgqQ0jGRrhGTAcrbZG3tVeFDpi7LQ6WdP3Syw2H+0D3GPyzpl/5UqU10Fum5Wr1br4QQ==",
|
||||
"version": "20.3.27",
|
||||
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-20.3.27.tgz",
|
||||
"integrity": "sha512-cNG26wi3tr3m8At6puxJpAMVk9mBhEqREA4Jk/klalMwWuYEf8ApTEAw0a5NUfMxDkL3dcJJwDRf8rK6ma6TYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
@@ -2070,16 +2070,16 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "20.3.26",
|
||||
"@angular/core": "20.3.26",
|
||||
"@angular/platform-browser": "20.3.26",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
"rxjs": "^6.5.3 || ^7.4.0",
|
||||
"@angular/core": "20.3.27",
|
||||
"@angular/common": "20.3.27",
|
||||
"@angular/platform-browser": "20.3.27"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/platform-browser": {
|
||||
"version": "20.3.26",
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.26.tgz",
|
||||
"integrity": "sha512-In4wUiLUUT9LqyV9Rjz78k/dsnKAwec4AtDmwZoX8/ZmeJOSH7g5X1gTM+hxTxmifmZkrapQjXR299IcfIkzrw==",
|
||||
"version": "20.3.27",
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.27.tgz",
|
||||
"integrity": "sha512-IV2zQ4zk6liyw5NE48bQqSk3nOAZ1rmDAQi7W4Kw0N8cs9MYVgK3zulo0zx5UqSv1kuNDAGb0HPR5tUGVOf9kw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
@@ -2088,9 +2088,9 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/animations": "20.3.26",
|
||||
"@angular/common": "20.3.26",
|
||||
"@angular/core": "20.3.26"
|
||||
"@angular/core": "20.3.27",
|
||||
"@angular/common": "20.3.27",
|
||||
"@angular/animations": "20.3.27"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@angular/animations": {
|
||||
@@ -2099,9 +2099,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/platform-browser-dynamic": {
|
||||
"version": "20.3.26",
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.3.26.tgz",
|
||||
"integrity": "sha512-/9eq0GGmtMoBV5UvcjvhnV4ZDcgZr15h+dzoxkZodUncb2z7fxybSyha7wWD9aTeabZ/q/KYVUSnoYqVyBhbVQ==",
|
||||
"version": "20.3.27",
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.3.27.tgz",
|
||||
"integrity": "sha512-4UUs8vOswgBOWCRoeZrswguarBLrM3j6WGb927Y3GXdN37fVJQYjyKHivNeQwZvwsGgl5Nl6mvpBpZv47n/OrQ==",
|
||||
"deprecated": "@angular/platform-browser-dynamic is deprecated. Use `@angular/platform-browser` instead.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2111,16 +2111,16 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "20.3.26",
|
||||
"@angular/compiler": "20.3.26",
|
||||
"@angular/core": "20.3.26",
|
||||
"@angular/platform-browser": "20.3.26"
|
||||
"@angular/core": "20.3.27",
|
||||
"@angular/common": "20.3.27",
|
||||
"@angular/compiler": "20.3.27",
|
||||
"@angular/platform-browser": "20.3.27"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/router": {
|
||||
"version": "20.3.26",
|
||||
"resolved": "https://registry.npmjs.org/@angular/router/-/router-20.3.26.tgz",
|
||||
"integrity": "sha512-q0k0b5uuQx93Trk4qEMYe8LoPOozheRBIjze51q+LUTlLXWik4W0ughXLiTJL346KRudR/vB5ksfZP8b6WlQyA==",
|
||||
"version": "20.3.27",
|
||||
"resolved": "https://registry.npmjs.org/@angular/router/-/router-20.3.27.tgz",
|
||||
"integrity": "sha512-F3hfJQ0GAuD6LdeB7A6fMfaErc4HXCtCQGh3C/8VrbVEbGfIgSveNjQXzGYPNklnOLhj1BmWf5w0WniUAEjLBA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
@@ -2129,10 +2129,10 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "20.3.26",
|
||||
"@angular/core": "20.3.26",
|
||||
"@angular/platform-browser": "20.3.26",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
"rxjs": "^6.5.3 || ^7.4.0",
|
||||
"@angular/core": "20.3.27",
|
||||
"@angular/common": "20.3.27",
|
||||
"@angular/platform-browser": "20.3.27"
|
||||
}
|
||||
},
|
||||
"node_modules/@arr/every": {
|
||||
@@ -11291,9 +11291,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
|
||||
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
@@ -14941,17 +14941,17 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.4",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
|
||||
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
|
||||
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
"url": "https://github.com/sponsors/fastify",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
"url": "https://opencollective.com/fastify",
|
||||
"type": "opencollective"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
@@ -25091,9 +25091,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "6.27.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz",
|
||||
"integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==",
|
||||
"version": "6.28.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
|
||||
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
+12
-9
@@ -41,15 +41,15 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^20.3.26",
|
||||
"@angular/animations": "^20.3.27",
|
||||
"@angular/cdk": "^20.2.14",
|
||||
"@angular/common": "^20.3.26",
|
||||
"@angular/compiler": "^20.3.26",
|
||||
"@angular/core": "^20.3.26",
|
||||
"@angular/forms": "^20.3.26",
|
||||
"@angular/platform-browser": "^20.3.26",
|
||||
"@angular/platform-browser-dynamic": "^20.3.26",
|
||||
"@angular/router": "^20.3.26",
|
||||
"@angular/common": "^20.3.27",
|
||||
"@angular/compiler": "^20.3.27",
|
||||
"@angular/core": "^20.3.27",
|
||||
"@angular/forms": "^20.3.27",
|
||||
"@angular/platform-browser": "^20.3.27",
|
||||
"@angular/platform-browser-dynamic": "^20.3.27",
|
||||
"@angular/router": "^20.3.27",
|
||||
"@cds/core": "^6.15.1",
|
||||
"@clr/angular": "file:libraries/clr-angular-17.9.0.tgz",
|
||||
"@clr/icons": "^13.0.2",
|
||||
@@ -100,7 +100,7 @@
|
||||
"@angular-eslint/schematics": "19.8.1",
|
||||
"@angular-eslint/template-parser": "19.8.1",
|
||||
"@angular/cli": "^20.3.32",
|
||||
"@angular/compiler-cli": "^20.3.26",
|
||||
"@angular/compiler-cli": "^20.3.27",
|
||||
"@babel/plugin-proposal-private-methods": "^7.18.6",
|
||||
"@compodoc/compodoc": "^2.0.0",
|
||||
"@cypress/webpack-preprocessor": "^5.17.1",
|
||||
@@ -148,6 +148,9 @@
|
||||
"ajv": "8.18.0",
|
||||
"uuid": "11.1.1",
|
||||
"lighthouse": "13.4.0",
|
||||
"readdir-glob": {
|
||||
"brace-expansion": "^5.0.9"
|
||||
},
|
||||
"exceljs": {
|
||||
"archiver": "^8.0.0",
|
||||
"unzipper": "^0.12.5"
|
||||
|
||||
@@ -41,6 +41,9 @@ 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 { getFormulaCellsToPreserveOnCancel } from '../shared/dc-validator/utils/getFormulaCellsToPreserveOnCancel'
|
||||
import { getStableFormulaBaseCols } from '../shared/dc-validator/utils/getStableFormulaBaseCols'
|
||||
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'
|
||||
@@ -201,6 +204,51 @@ 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',
|
||||
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 commentsPlugin: any = this.getPlugin('comments')
|
||||
|
||||
return !commentsPlugin.getCommentAtCell(from.row, from.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 isNumericCol =
|
||||
this.dcValidator?.getRule(prop)?.type === 'numeric'
|
||||
|
||||
hot.setDataAtRowProp(
|
||||
row,
|
||||
prop,
|
||||
isNumericCol ? Number(rawValueText) : rawValueText
|
||||
)
|
||||
commentsPlugin.removeCommentAtCell(row, col)
|
||||
}
|
||||
},
|
||||
row_above: {
|
||||
name: 'Insert Row above',
|
||||
hidden: () => this.hotTable.readOnly === true,
|
||||
@@ -357,6 +405,10 @@ 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.
|
||||
private static readonly ORIGINAL_VALUE_COMMENT_PREFIX = 'Original value: '
|
||||
public tableTrue: boolean | undefined
|
||||
public saveLoading = false
|
||||
public approvers: string[] = []
|
||||
@@ -411,6 +463,14 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
dataSource!: any[]
|
||||
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
|
||||
// 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
|
||||
// change across edit sessions.
|
||||
dataSourceRaw!: any[]
|
||||
dataSourceBeforeSubmit!: any[]
|
||||
dataModified!: any[]
|
||||
|
||||
@@ -1034,6 +1094,8 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
if (newRow) {
|
||||
this.dataSourceUnchanged.pop()
|
||||
}
|
||||
|
||||
this.overlayFormulaRawValuesOnUnchanged(this.dataSourceUnchanged)
|
||||
}
|
||||
this.hotTable.readOnly = false
|
||||
this.hotTable.data = this.dataSource
|
||||
@@ -1098,7 +1160,34 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
: [columnSortConfig]
|
||||
|
||||
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
|
||||
// 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
|
||||
// below, then patch them back in - otherwise cancelling would freeze
|
||||
// the display at the raw value and erase the row's modified marker.
|
||||
const formulaBaseCols = this.getFormulaBaseCols()
|
||||
const commentsPlugin = hot.getPlugin('comments')
|
||||
const toPreserve = getFormulaCellsToPreserveOnCancel(
|
||||
this.dataSource.length,
|
||||
formulaBaseCols,
|
||||
(rowIndex, baseCol) =>
|
||||
!!commentsPlugin.getCommentAtCell(
|
||||
rowIndex,
|
||||
hot.propToCol(baseCol) as number
|
||||
),
|
||||
(rowIndex, prop) => hot.getDataAtRowProp(rowIndex, prop)
|
||||
)
|
||||
|
||||
this.dataSource = this.helperService.deepClone(this.dataSourceUnchanged)
|
||||
|
||||
for (const cell of toPreserve) {
|
||||
if (this.dataSource[cell.rowIndex]) {
|
||||
this.dataSource[cell.rowIndex][cell.prop] = cell.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.hotTable.data = this.dataSource
|
||||
@@ -1404,6 +1493,129 @@ 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
|
||||
* uses above, which seeds every formula column regardless of this
|
||||
* distinction.
|
||||
*/
|
||||
private getFormulaBaseCols(): string[] {
|
||||
const dqRules = this.dcValidator?.getDqDetails()
|
||||
if (!dqRules) return []
|
||||
|
||||
return getStableFormulaBaseCols(dqRules)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
* by the initial load (so the row header agrees with EDIT_STATUS from the
|
||||
* very first render) and editTable() (every time the baseline is rebuilt).
|
||||
*/
|
||||
private overlayFormulaRawValuesOnUnchanged(dataSourceUnchanged: any[]): void {
|
||||
const formulaBaseCols = this.getFormulaBaseCols()
|
||||
if (formulaBaseCols.length === 0 || !this.dataSourceRaw) return
|
||||
|
||||
dataSourceUnchanged.forEach((row, rowIndex) => {
|
||||
const rawRow = this.dataSourceRaw[rowIndex]
|
||||
if (!rawRow) return
|
||||
|
||||
for (const baseCol of formulaBaseCols) {
|
||||
const rawValue = rawRow[baseCol]
|
||||
if (rawValue === undefined || rawValue === null || rawValue === '')
|
||||
continue
|
||||
|
||||
row[baseCol] = rawValue
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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().
|
||||
*/
|
||||
private markFormulaChangedCells(): void {
|
||||
const hot = this.hotInstance
|
||||
const formulaBaseCols = this.getFormulaBaseCols()
|
||||
if (formulaBaseCols.length === 0 || !this.dataSourceRaw) return
|
||||
|
||||
const computedRows = this.dataSource.map((_row, rowIndex) =>
|
||||
Object.fromEntries(
|
||||
formulaBaseCols.map((baseCol) => [
|
||||
baseCol,
|
||||
hot.getDataAtRowProp(rowIndex, baseCol)
|
||||
])
|
||||
)
|
||||
)
|
||||
|
||||
const changes = findFormulaValueChanges(
|
||||
computedRows,
|
||||
this.dataSourceRaw,
|
||||
formulaBaseCols
|
||||
)
|
||||
|
||||
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
|
||||
// exist yet this early (only editTable() sets it), and these rows are
|
||||
// already known-modified from the comment loop above; no need to
|
||||
// reclassify.
|
||||
//
|
||||
// Deferred: setDataAtRowProp needs the Formulas plugin's hidden-column
|
||||
// index mapping to have completed at least one settle pass. Called
|
||||
// synchronously, right after the initial hot.updateSettings(), that
|
||||
// mapping isn't ready yet and HyperFormula throws
|
||||
// ExpectedValueOfTypeError (address.col resolves to undefined for the
|
||||
// trimmed/hidden dc.row_status column) - since ngOnInit's getdata
|
||||
// .catch() swallows anything thrown here, that leaves the whole table
|
||||
// silently hidden. editTable() defers its own post-render work the
|
||||
// same way, for the same index-mapping reason.
|
||||
if (changedRows.size > 0) {
|
||||
setTimeout(() => {
|
||||
for (const rowIndex of changedRows) {
|
||||
hot.setDataAtRowProp(
|
||||
rowIndex,
|
||||
EDIT_STATUS_COLUMN_NAME,
|
||||
'M',
|
||||
'editStatus'
|
||||
)
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recomputes this row's classification (M/A/D/U) and writes it into the
|
||||
* EDIT_STATUS cell - the same classification the row-header symbol shows,
|
||||
@@ -1958,7 +2170,34 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
public async saveTable(data: any) {
|
||||
const hot = this.hotInstance
|
||||
const hotData = hot.getData()
|
||||
|
||||
// HARDFORMULA/SOFTFORMULA columns hold a live '=...' formula string in
|
||||
// dataSource - HyperFormula recalculates the DISPLAYED value without
|
||||
// ever mutating that underlying string, so submitting dataSource as-is
|
||||
// would send the formula text itself instead of its computed result.
|
||||
// getDataAtRowProp reads through the formulas plugin's own modifyData
|
||||
// hook, which resolves the engine's live value for that cell. Must run
|
||||
// over the full, unfiltered `data` (physical/array order, same
|
||||
// reference as dataSource) - its index is what toVisualRow/
|
||||
// getDataAtRowProp need; the filtered/mapped array below no longer
|
||||
// lines up with actual grid rows.
|
||||
const formulaRules = (this.dcValidator?.getDqDetails() ?? []).filter(
|
||||
(rule) =>
|
||||
rule.RULE_TYPE === 'HARDFORMULA' || rule.RULE_TYPE === 'SOFTFORMULA'
|
||||
)
|
||||
|
||||
if (formulaRules.length > 0) {
|
||||
data.forEach((row: any, physicalRowIndex: number) => {
|
||||
const visualRowIndex = hot.toVisualRow(physicalRowIndex)
|
||||
|
||||
for (const rule of formulaRules) {
|
||||
row[rule.BASE_COL] = hot.getDataAtRowProp(
|
||||
visualRowIndex,
|
||||
rule.BASE_COL
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
data = data.filter((dataRow: any) => {
|
||||
const elModified = this.dataModified.find((row) => {
|
||||
@@ -2837,6 +3076,11 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.initSetup(res)
|
||||
})
|
||||
.catch((err: any) => {
|
||||
// A synchronous throw inside initSetup (called from the .then()
|
||||
// above) lands here too, not just a network failure - log it, or
|
||||
// the table just renders hidden with no clue why.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('editors/getdata failed:', err)
|
||||
this.getdataError = true
|
||||
this.tableTrue = true
|
||||
})
|
||||
@@ -3245,6 +3489,12 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.dataSource = response.data.sasdata
|
||||
this.$dataFormats = response.data.$sasdata
|
||||
|
||||
// Raw, as-received snapshot - captured before applyFormulaRules below
|
||||
// overwrites HARDFORMULA/SOFTFORMULA columns, so markFormulaChangedCells
|
||||
// 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)
|
||||
|
||||
// Seed every row's EDIT_STATUS to 'U' before anything (HyperFormula
|
||||
// included) ever sees this data - nothing has been edited yet, and this
|
||||
// is also the baseline dataSourceUnchanged will later be cloned from, so
|
||||
@@ -3268,6 +3518,15 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.userService.user?.username ?? ''
|
||||
)
|
||||
|
||||
// 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
|
||||
// '~' 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.
|
||||
this.dataSourceUnchanged = this.helperService.deepClone(this.dataSource)
|
||||
this.overlayFormulaRawValuesOnUnchanged(this.dataSourceUnchanged)
|
||||
|
||||
// Note: this.headerColumns and this.columnHeader contains same data
|
||||
// need to resolve redundancy
|
||||
|
||||
@@ -3299,6 +3558,25 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
columns: this.cellValidation,
|
||||
height: this.hotTable.height,
|
||||
formulas: this.hotTable.formulas,
|
||||
// The Formulas plugin resolves a =CELL_REF formula (e.g. what
|
||||
// DC.ROW_STATUS compiles to, see parseFormulaRule.ts) through its own
|
||||
// data-sync path into HyperFormula - a different path than
|
||||
// getDataAtRowProp/datamap.get() (see editStatusColumnRule.ts for
|
||||
// why a dotted `data` key is otherwise safe there). That path
|
||||
// doesn't check for an own flat property before falling back to
|
||||
// nested dot-notation, so a referenced cell whose `data` key
|
||||
// contains a literal '.' (like dc.row_status) resolves to
|
||||
// undefined/0 instead of its real value. This app never uses nested
|
||||
// `data` paths, so disabling dot-notation entirely lets
|
||||
// DC.ROW_STATUS (and anything that branches on it, e.g.
|
||||
// CHANGE_SUMMARY_COL's IF) resolve correctly without giving up the
|
||||
// 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.
|
||||
comments: { readOnly: true },
|
||||
stretchH: 'all',
|
||||
readOnly: this.hotTable.readOnly,
|
||||
hiddenColumns: {
|
||||
@@ -3496,6 +3774,12 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
false
|
||||
)
|
||||
|
||||
// Must run after the updateSettings() call above: getDataAtRowProp only
|
||||
// resolves formulas' live computed values once HyperFormula has
|
||||
// actually evaluated them against the data/formulas settings just
|
||||
// applied.
|
||||
this.markFormulaChangedCells()
|
||||
|
||||
this.hotTable.hidden = false
|
||||
// Keep the context menu enabled in view mode too so Copy/Export remain
|
||||
// available; editing items hide themselves when read-only.
|
||||
|
||||
@@ -4,7 +4,7 @@ import { applyFormulaRules } from './applyFormulaRules'
|
||||
const rule = (overrides: Partial<DQRule>): DQRule => ({
|
||||
BASE_COL: 'REVENUE',
|
||||
RULE_TYPE: 'HARDFORMULA',
|
||||
RULE_VALUE: 'PRICE * VOLUME',
|
||||
RULE_VALUE: '=PRICE * VOLUME',
|
||||
X: 0,
|
||||
...overrides
|
||||
})
|
||||
@@ -70,7 +70,7 @@ describe('applyFormulaRules', () => {
|
||||
|
||||
const result = applyFormulaRules(
|
||||
dataSource,
|
||||
[rule({ BASE_COL: 'NOTE', RULE_VALUE: 'DC.ORIG_VALUE' })],
|
||||
[rule({ BASE_COL: 'NOTE', RULE_VALUE: '=DC.ORIG_VALUE' })],
|
||||
['ITEM', 'PRICE', 'VOLUME', 'NOTE'],
|
||||
dataSourceUnchanged,
|
||||
headerPks,
|
||||
@@ -85,7 +85,7 @@ describe('applyFormulaRules', () => {
|
||||
|
||||
const result = applyFormulaRules(
|
||||
dataSource,
|
||||
[rule({ BASE_COL: 'NOTE', RULE_VALUE: 'DC.ORIG_VALUE' })],
|
||||
[rule({ BASE_COL: 'NOTE', RULE_VALUE: '=DC.ORIG_VALUE' })],
|
||||
['ITEM', 'PRICE', 'VOLUME', 'NOTE'],
|
||||
[],
|
||||
headerPks,
|
||||
@@ -103,7 +103,7 @@ describe('applyFormulaRules', () => {
|
||||
|
||||
const result = applyFormulaRules(
|
||||
dataSource,
|
||||
[rule({ BASE_COL: 'NOTE', RULE_VALUE: 'DC.USER_NAME' })],
|
||||
[rule({ BASE_COL: 'NOTE', RULE_VALUE: '=DC.USER_NAME' })],
|
||||
['ITEM', 'PRICE', 'VOLUME', 'NOTE'],
|
||||
dataSource,
|
||||
headerPks,
|
||||
|
||||
@@ -7,13 +7,16 @@ import { DcValidation } from '../models/dc-validation.model'
|
||||
* DcValidator's constructor) and is stripped before the submit payload
|
||||
* leaves the browser (see editor.component.ts's saveTable()).
|
||||
*
|
||||
* Heavily decorated with underscores, same convention as
|
||||
* DELETE_RECORD_COLUMN_RULE - a plain 'EDIT_STATUS' could collide with a
|
||||
* real column of that name in the actual dataset, which would silently
|
||||
* overwrite that column's real data (see editor.component.ts's seeding
|
||||
* loop) and drop it from the submit payload entirely.
|
||||
* Named with a period rather than decorated with underscores (like
|
||||
* DELETE_RECORD_COLUMN_RULE) - a SAS variable name can never contain a
|
||||
* period, so this makes a collision with a real dataset column structurally
|
||||
* impossible, not just unlikely. Handsontable's own datamap.get()/set() only
|
||||
* treat a dot in a `data` key as a nested-path separator when the row has
|
||||
* no OWN flat property of that exact name - since every row always gets
|
||||
* this key from hotDataSchema (a flat copy, dots included, see
|
||||
* DataMap.createRow's deepExtend), that fallback never triggers here.
|
||||
*/
|
||||
export const EDIT_STATUS_COLUMN_NAME = '_____EDIT_STATUS_____'
|
||||
export const EDIT_STATUS_COLUMN_NAME = 'dc.row_status'
|
||||
|
||||
export const EDIT_STATUS_COLUMN_RULE: DcValidation = {
|
||||
data: EDIT_STATUS_COLUMN_NAME,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
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 }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
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,75 @@
|
||||
import { getFormulaCellsToPreserveOnCancel } from './getFormulaCellsToPreserveOnCancel'
|
||||
|
||||
describe('getFormulaCellsToPreserveOnCancel', () => {
|
||||
it('preserves a formula column that still has its changed-value comment', () => {
|
||||
const hasComment = (rowIndex: number, baseCol: string) =>
|
||||
rowIndex === 0 && baseCol === 'FORMULA_HARD_COL'
|
||||
const getComputedValue = (rowIndex: number, prop: string) =>
|
||||
`computed-${rowIndex}-${prop}`
|
||||
|
||||
expect(
|
||||
getFormulaCellsToPreserveOnCancel(
|
||||
1,
|
||||
['FORMULA_HARD_COL'],
|
||||
hasComment,
|
||||
getComputedValue
|
||||
)
|
||||
).toEqual([
|
||||
{
|
||||
rowIndex: 0,
|
||||
prop: 'FORMULA_HARD_COL',
|
||||
value: 'computed-0-FORMULA_HARD_COL'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('skips a formula column with no comment - either it never differed, or the user already reverted it', () => {
|
||||
expect(
|
||||
getFormulaCellsToPreserveOnCancel(
|
||||
1,
|
||||
['FORMULA_HARD_COL'],
|
||||
() => false,
|
||||
() => 'unused'
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('checks every row and every formula base col independently', () => {
|
||||
const hasComment = (rowIndex: number, baseCol: string) =>
|
||||
(rowIndex === 0 && baseCol === 'FORMULA_HARD_COL') ||
|
||||
(rowIndex === 2 && baseCol === 'FORMULA_SOFT_COL')
|
||||
const getComputedValue = (rowIndex: number, prop: string) =>
|
||||
`computed-${rowIndex}-${prop}`
|
||||
|
||||
expect(
|
||||
getFormulaCellsToPreserveOnCancel(
|
||||
3,
|
||||
['FORMULA_HARD_COL', 'FORMULA_SOFT_COL'],
|
||||
hasComment,
|
||||
getComputedValue
|
||||
)
|
||||
).toEqual([
|
||||
{
|
||||
rowIndex: 0,
|
||||
prop: 'FORMULA_HARD_COL',
|
||||
value: 'computed-0-FORMULA_HARD_COL'
|
||||
},
|
||||
{
|
||||
rowIndex: 2,
|
||||
prop: 'FORMULA_SOFT_COL',
|
||||
value: 'computed-2-FORMULA_SOFT_COL'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('returns nothing when there are no formula base cols', () => {
|
||||
expect(
|
||||
getFormulaCellsToPreserveOnCancel(
|
||||
5,
|
||||
[],
|
||||
() => true,
|
||||
() => 'unused'
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
export interface PreservedFormulaCell {
|
||||
rowIndex: number
|
||||
prop: string
|
||||
value: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Cells that must keep their LIVE computed value when an edit session is
|
||||
* cancelled, rather than being blindly overwritten by dataSourceUnchanged's
|
||||
* raw baseline. dataSourceUnchanged deliberately holds the raw pre-formula
|
||||
* value for a HARDFORMULA/SOFTFORMULA column whenever it still has its
|
||||
* markFormulaChangedCells comment (see editTable's overlay) - that's needed
|
||||
* so classifyRow flags the row as modified, but it isn't a session edit to
|
||||
* discard: the formula recomputes on every load regardless of what happens
|
||||
* to be sitting in dataSourceUnchanged.
|
||||
*
|
||||
* A cell with NO comment doesn't need preserving either way: it never
|
||||
* differed from raw (dataSourceUnchanged already matches computed), or the
|
||||
* user already reverted it (dataSourceUnchanged's raw value already matches
|
||||
* the reverted plain value sitting in dataSource) - in both cases the
|
||||
* baseline is already correct.
|
||||
*/
|
||||
export const getFormulaCellsToPreserveOnCancel = (
|
||||
rowCount: number,
|
||||
formulaBaseCols: string[],
|
||||
hasComment: (rowIndex: number, baseCol: string) => boolean,
|
||||
getComputedValue: (rowIndex: number, prop: string) => unknown
|
||||
): PreservedFormulaCell[] => {
|
||||
const preserved: PreservedFormulaCell[] = []
|
||||
|
||||
for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
|
||||
for (const baseCol of formulaBaseCols) {
|
||||
if (!hasComment(rowIndex, baseCol)) continue
|
||||
|
||||
preserved.push({
|
||||
rowIndex,
|
||||
prop: baseCol,
|
||||
value: getComputedValue(rowIndex, baseCol)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return preserved
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { DQRule } from '../models/dq-rules.model'
|
||||
import { getStableFormulaBaseCols } from './getStableFormulaBaseCols'
|
||||
|
||||
const rule = (overrides: Partial<DQRule>): DQRule => ({
|
||||
BASE_COL: 'SOME_COL',
|
||||
RULE_TYPE: 'SOFTFORMULA',
|
||||
RULE_VALUE: '=A_COL + B_COL',
|
||||
X: 0,
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('getStableFormulaBaseCols', () => {
|
||||
it('includes a plain column-arithmetic HARDFORMULA/SOFTFORMULA rule', () => {
|
||||
expect(
|
||||
getStableFormulaBaseCols([
|
||||
rule({ BASE_COL: 'FORMULA_HARD_COL', RULE_TYPE: 'HARDFORMULA' }),
|
||||
rule({ BASE_COL: 'FORMULA_SOFT_COL', RULE_TYPE: 'SOFTFORMULA' })
|
||||
])
|
||||
).toEqual(['FORMULA_HARD_COL', 'FORMULA_SOFT_COL'])
|
||||
})
|
||||
|
||||
it('excludes a rule referencing DC.ORIG_VALUE - its own raw value is expected to differ from a stable computed default', () => {
|
||||
expect(
|
||||
getStableFormulaBaseCols([
|
||||
rule({ BASE_COL: 'ORIG_VALUE_COL', RULE_VALUE: '=DC.ORIG_VALUE' })
|
||||
])
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('excludes a rule referencing DC.USER_NAME', () => {
|
||||
expect(
|
||||
getStableFormulaBaseCols([
|
||||
rule({ BASE_COL: 'USER_NAME_COL', RULE_VALUE: '=DC.USER_NAME' })
|
||||
])
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('excludes a rule referencing DC.ROW_STATUS', () => {
|
||||
expect(
|
||||
getStableFormulaBaseCols([
|
||||
rule({ BASE_COL: 'ROW_STATUS_COL', RULE_VALUE: '=DC.ROW_STATUS' })
|
||||
])
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('excludes a rule combining multiple DC.* variables, even alongside real column names', () => {
|
||||
expect(
|
||||
getStableFormulaBaseCols([
|
||||
rule({
|
||||
BASE_COL: 'CHANGE_SUMMARY_COL',
|
||||
RULE_VALUE:
|
||||
'=IF( DC.ROW_STATUS ="U","unedited", DC.USER_NAME &" changed from "& DC.ORIG_VALUE )'
|
||||
})
|
||||
])
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores non-formula rules entirely (e.g. NOTNULL)', () => {
|
||||
expect(
|
||||
getStableFormulaBaseCols([
|
||||
rule({ BASE_COL: 'PRIMARY_KEY_FIELD', RULE_TYPE: 'NOTNULL' })
|
||||
])
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { DQRule } from '../models/dq-rules.model'
|
||||
|
||||
/**
|
||||
* BASE_COL names of every HARDFORMULA/SOFTFORMULA rule whose result is
|
||||
* expected to be *stable* for a given row - i.e. excludes rules
|
||||
* referencing DC.USER_NAME/DC.ORIG_VALUE/DC.ROW_STATUS. Those three are
|
||||
* inherently session/edit-state-dependent (who's editing, what the row
|
||||
* looked like before, whether it's currently modified) - comparing their
|
||||
* live result against a frozen raw value doesn't mean "the formula
|
||||
* overwrote real data" the way it does for a plain column-arithmetic
|
||||
* formula (e.g. A_COL * B_COL); it would just always differ, for reasons
|
||||
* unrelated to the dataset ever having real data there.
|
||||
*/
|
||||
export const getStableFormulaBaseCols = (dqRules: DQRule[]): string[] =>
|
||||
dqRules
|
||||
.filter(
|
||||
(rule) =>
|
||||
(rule.RULE_TYPE === 'HARDFORMULA' ||
|
||||
rule.RULE_TYPE === 'SOFTFORMULA') &&
|
||||
!/DC\.(USER_NAME|ORIG_VALUE|ROW_STATUS)/.test(rule.RULE_VALUE)
|
||||
)
|
||||
.map((rule) => rule.BASE_COL)
|
||||
@@ -11,12 +11,12 @@ const context = (overrides: Partial<FormulaVariableContext> = {}) => ({
|
||||
|
||||
describe('parseFormulaRule', () => {
|
||||
it("substitutes column-name variables with this row's cell references (issue's own PRICE/VOLUME example)", () => {
|
||||
expect(parseFormulaRule('PRICE * VOLUME', context())).toEqual('=B1 * C1')
|
||||
expect(parseFormulaRule('=PRICE * VOLUME', context())).toEqual('=B1 * C1')
|
||||
})
|
||||
|
||||
it('uses the row-relative reference for a later row', () => {
|
||||
expect(
|
||||
parseFormulaRule('PRICE * VOLUME', context({ rowIndex: 1 }))
|
||||
parseFormulaRule('=PRICE * VOLUME', context({ rowIndex: 1 }))
|
||||
).toEqual('=B2 * C2')
|
||||
})
|
||||
|
||||
@@ -24,36 +24,40 @@ describe('parseFormulaRule', () => {
|
||||
// No spaces around '*' - PRICE/VOLUME here should NOT be recognized as
|
||||
// variables (the issue's own rule: a named variable must have a
|
||||
// leading and trailing blank to avoid clashing with function names).
|
||||
expect(parseFormulaRule('PRICE*VOLUME', context())).toEqual('=PRICE*VOLUME')
|
||||
expect(parseFormulaRule('=PRICE*VOLUME', context())).toEqual(
|
||||
'=PRICE*VOLUME'
|
||||
)
|
||||
})
|
||||
|
||||
it("does not substitute a variable name matched inside a function call with no surrounding blanks (the issue's MATCH() clash example)", () => {
|
||||
// MATCH is not one of our column names here, but this proves adjacency
|
||||
// to parens alone doesn't trigger substitution - only literal
|
||||
// surrounding whitespace (or string start/end) does.
|
||||
expect(parseFormulaRule('MATCH(PRICE)', context())).toEqual('=MATCH(PRICE)')
|
||||
expect(parseFormulaRule('=MATCH(PRICE)', context())).toEqual(
|
||||
'=MATCH(PRICE)'
|
||||
)
|
||||
})
|
||||
|
||||
it("leaves variable occurrences inside quoted strings untouched (the issue's own ITEM example)", () => {
|
||||
expect(parseFormulaRule('ITEM & " string ITEM "', context())).toEqual(
|
||||
expect(parseFormulaRule('=ITEM & " string ITEM "', context())).toEqual(
|
||||
'=A1 & " string ITEM "'
|
||||
)
|
||||
})
|
||||
|
||||
it('substitutes DC.USER_NAME with the quoted, literal current username', () => {
|
||||
expect(
|
||||
parseFormulaRule('DC.USER_NAME', context({ userName: 'sasdemo' }))
|
||||
parseFormulaRule('=DC.USER_NAME', context({ userName: 'sasdemo' }))
|
||||
).toEqual('=\"sasdemo\"')
|
||||
})
|
||||
|
||||
it('substitutes DC.ORIG_VALUE with the quoted, literal original cell value', () => {
|
||||
expect(
|
||||
parseFormulaRule('DC.ORIG_VALUE', context({ origValue: 'sasinstaller' }))
|
||||
parseFormulaRule('=DC.ORIG_VALUE', context({ origValue: 'sasinstaller' }))
|
||||
).toEqual('=\"sasinstaller\"')
|
||||
})
|
||||
|
||||
it('does not substitute DC.USER_NAME/DC.ORIG_VALUE inside quoted strings either', () => {
|
||||
expect(parseFormulaRule('"DC.USER_NAME"', context())).toEqual(
|
||||
expect(parseFormulaRule('="DC.USER_NAME"', context())).toEqual(
|
||||
'=\"DC.USER_NAME\"'
|
||||
)
|
||||
})
|
||||
@@ -61,7 +65,7 @@ describe('parseFormulaRule', () => {
|
||||
it('substitutes DC.ROW_STATUS with a cell reference to the EDIT_STATUS column, not a quoted literal', () => {
|
||||
expect(
|
||||
parseFormulaRule(
|
||||
'IF( DC.ROW_STATUS ="U","unchanged","changed")',
|
||||
'=IF( DC.ROW_STATUS ="U","unchanged","changed")',
|
||||
context({
|
||||
columnNames: ['ITEM', 'PRICE', 'VOLUME', EDIT_STATUS_COLUMN_NAME],
|
||||
rowIndex: 0
|
||||
@@ -73,7 +77,7 @@ describe('parseFormulaRule', () => {
|
||||
it('uses the row-relative reference for DC.ROW_STATUS on a later row', () => {
|
||||
expect(
|
||||
parseFormulaRule(
|
||||
'DC.ROW_STATUS',
|
||||
'=DC.ROW_STATUS',
|
||||
context({
|
||||
columnNames: ['ITEM', EDIT_STATUS_COLUMN_NAME],
|
||||
rowIndex: 4
|
||||
@@ -83,14 +87,18 @@ describe('parseFormulaRule', () => {
|
||||
})
|
||||
|
||||
it('leaves DC.ROW_STATUS untouched when the EDIT_STATUS column is not present', () => {
|
||||
expect(parseFormulaRule('DC.ROW_STATUS', context())).toEqual(
|
||||
expect(parseFormulaRule('=DC.ROW_STATUS', context())).toEqual(
|
||||
'=DC.ROW_STATUS'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not substitute DC.ROW_STATUS inside quoted strings either', () => {
|
||||
expect(parseFormulaRule('"DC.ROW_STATUS"', context())).toEqual(
|
||||
expect(parseFormulaRule('="DC.ROW_STATUS"', context())).toEqual(
|
||||
'=\"DC.ROW_STATUS\"'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not insert a leading = when the rule value does not have one, so HOT treats it as plain text rather than a formula', () => {
|
||||
expect(parseFormulaRule('PRICE * VOLUME', context())).toEqual('B1 * C1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -42,6 +42,14 @@ export const parseFormulaRule = (
|
||||
ruleValue: string,
|
||||
context: FormulaVariableContext
|
||||
): string => {
|
||||
// A leading '=' is the rule author's own responsibility- it's not
|
||||
// inserted here. It's stripped before
|
||||
// substitution and re-attached after so substituteBoundedToken's
|
||||
// start-of-string boundary check still lines up with the first real
|
||||
// token, exactly as if it had never been there.
|
||||
const hasLeadingEquals = ruleValue.startsWith('=')
|
||||
const formulaBody = hasLeadingEquals ? ruleValue.slice(1) : ruleValue
|
||||
|
||||
const quotedSpanPattern = /"[^"]*"|'[^']*'/g
|
||||
let result = ''
|
||||
let lastIndex = 0
|
||||
@@ -83,12 +91,12 @@ export const parseFormulaRule = (
|
||||
return substituted
|
||||
}
|
||||
|
||||
while ((match = quotedSpanPattern.exec(ruleValue))) {
|
||||
result += substituteUnquotedSpan(ruleValue.slice(lastIndex, match.index))
|
||||
while ((match = quotedSpanPattern.exec(formulaBody))) {
|
||||
result += substituteUnquotedSpan(formulaBody.slice(lastIndex, match.index))
|
||||
result += match[0]
|
||||
lastIndex = match.index + match[0].length
|
||||
}
|
||||
result += substituteUnquotedSpan(ruleValue.slice(lastIndex))
|
||||
result += substituteUnquotedSpan(formulaBody.slice(lastIndex))
|
||||
|
||||
return `=${result}`
|
||||
return hasLeadingEquals ? `=${result}` : result
|
||||
}
|
||||
|
||||
@@ -96,6 +96,27 @@ describe('buildColInfoHtml', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('does not double the = when the formula value itself already has a leading = (RULE_VALUE is stored with it, see parseFormulaRule.ts)', () => {
|
||||
const colInfo: DataFormat = {
|
||||
label: 'Some Character Column',
|
||||
type: 'char',
|
||||
length: '1024',
|
||||
format: '$1024.'
|
||||
}
|
||||
|
||||
expect(
|
||||
buildColInfoHtml(
|
||||
'SOME_CHAR',
|
||||
colInfo,
|
||||
undefined,
|
||||
undefined,
|
||||
'=SOME_SHORTNUM * SOME_BESTNUM'
|
||||
)
|
||||
).toBe(
|
||||
'NAME: SOME_CHAR<br>LABEL: Some Character Column<br>TYPE: char<br>LENGTH: 1024<br>FORMAT: $1024.<br>√x=SOME_SHORTNUM * SOME_BESTNUM'
|
||||
)
|
||||
})
|
||||
|
||||
it('omits the formula line when no formula value is provided', () => {
|
||||
const colInfo: DataFormat = {
|
||||
label: 'Some Character Column',
|
||||
|
||||
@@ -28,9 +28,15 @@ export function buildColInfoHtml(
|
||||
|
||||
// '√x=' stands in for a text label here - HARDFORMULA vs SOFTFORMULA is
|
||||
// already conveyed by the column's readOnly state, so there's no need to
|
||||
// spell out which one this is.
|
||||
// spell out which one this is. formulaValue is the raw RULE_VALUE, which
|
||||
// may or may not include its own leading '=' (see parseFormulaRule.ts -
|
||||
// it's the rule author's choice, not inserted) - strip it here so it's
|
||||
// never doubled against this label's own '='.
|
||||
if (formulaValue) {
|
||||
html += `<br>√x=${formulaValue}`
|
||||
const formula = formulaValue.startsWith('=')
|
||||
? formulaValue.slice(1)
|
||||
: formulaValue
|
||||
html += `<br>√x=${formula}`
|
||||
}
|
||||
|
||||
return html
|
||||
|
||||
@@ -1627,12 +1627,12 @@ let webouts = {
|
||||
dqdata: [],
|
||||
dqrules: [
|
||||
{ BASE_COL: "PRIMARY_KEY_FIELD", RULE_TYPE: "NOTNULL", RULE_VALUE: "" },
|
||||
{ BASE_COL: "FORMULA_HARD_COL", RULE_TYPE: "HARDFORMULA", RULE_VALUE: "A_COL * B_COL" },
|
||||
{ BASE_COL: "FORMULA_SOFT_COL", RULE_TYPE: "SOFTFORMULA", RULE_VALUE: "A_COL + B_COL" },
|
||||
{ BASE_COL: "ROW_STATUS_COL", RULE_TYPE: "SOFTFORMULA", RULE_VALUE: "DC.ROW_STATUS" },
|
||||
{ BASE_COL: "USER_NAME_COL", RULE_TYPE: "SOFTFORMULA", RULE_VALUE: "DC.USER_NAME" },
|
||||
{ BASE_COL: "ORIG_VALUE_COL", RULE_TYPE: "SOFTFORMULA", RULE_VALUE: "DC.ORIG_VALUE" },
|
||||
{ BASE_COL: "CHANGE_SUMMARY_COL", RULE_TYPE: "SOFTFORMULA", RULE_VALUE: "IF( DC.ROW_STATUS =\"U\",\"unedited\", DC.USER_NAME &\" changed from \"& DC.ORIG_VALUE )" }
|
||||
{ BASE_COL: "FORMULA_HARD_COL", RULE_TYPE: "HARDFORMULA", RULE_VALUE: "=A_COL * B_COL" },
|
||||
{ BASE_COL: "FORMULA_SOFT_COL", RULE_TYPE: "SOFTFORMULA", RULE_VALUE: "=A_COL + B_COL" },
|
||||
{ BASE_COL: "ROW_STATUS_COL", RULE_TYPE: "SOFTFORMULA", RULE_VALUE: "=DC.ROW_STATUS" },
|
||||
{ BASE_COL: "USER_NAME_COL", RULE_TYPE: "SOFTFORMULA", RULE_VALUE: "=DC.USER_NAME" },
|
||||
{ BASE_COL: "ORIG_VALUE_COL", RULE_TYPE: "SOFTFORMULA", RULE_VALUE: "=DC.ORIG_VALUE" },
|
||||
{ BASE_COL: "CHANGE_SUMMARY_COL", RULE_TYPE: "SOFTFORMULA", RULE_VALUE: "=IF( DC.ROW_STATUS =\"U\",\"unedited\", DC.USER_NAME &\" changed from \"& DC.ORIG_VALUE )" }
|
||||
],
|
||||
dsmeta: [
|
||||
{ ODS_TABLE: "ATTRIBUTES", NAME: "Data Set Name", VALUE: "MPE_X_FORMULA_TEST" },
|
||||
@@ -1659,14 +1659,17 @@ 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.
|
||||
// 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.
|
||||
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: "",
|
||||
FORMULA_SOFT_COL: "",
|
||||
FORMULA_HARD_COL: i === 9 ? "1111" : "",
|
||||
FORMULA_SOFT_COL: i === 9 ? "2222" : "",
|
||||
ROW_STATUS_COL: "",
|
||||
USER_NAME_COL: "",
|
||||
ORIG_VALUE_COL: `orig-${i + 1}`,
|
||||
|
||||
Reference in New Issue
Block a user