Merge branch 'version7-13' into issue-148
Build / Build-and-ng-test (pull_request) Successful in 5m57s
Lighthouse Checks / lighthouse (pull_request) Successful in 25m2s
Build / Build-and-test-development (pull_request) Successful in 23m12s

This commit is contained in:
2026-08-05 09:01:50 +00:00
17 changed files with 1006 additions and 134 deletions
+211 -5
View File
@@ -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
+82 -82
View File
@@ -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
View File
@@ -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"
+285 -1
View File
@@ -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',
+8 -2
View File
@@ -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
+12 -9
View File
@@ -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}`,