fix(editor): resolve a primary key's live formula to its computed value, not the raw formula text
This commit is contained in:
@@ -1652,6 +1652,123 @@ context('editor tests: ', function () {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// A primary key identifies its own row for the rest of the edit session
|
||||
// (dataModified/classifyRow match rows by PK) - a live, still-
|
||||
// recalculating formula left sitting in a PK cell would let that
|
||||
// identity drift between when it's entered and when submission finally
|
||||
// resolves it, silently dropping the row from what's submitted. So
|
||||
// unlike B_COL above, a formula pasted into PRIMARY_KEY_FIELD resolves
|
||||
// to its computed value immediately, the moment the paste completes,
|
||||
// rather than staying live until submit. Row 4 (PK=5): pasting
|
||||
// `=999+1` evaluates to 1000, a value clearly different from both the
|
||||
// original and every other row's PK, so a passing assertion here can't
|
||||
// be a coincidence (nor a PK collision with another seeded row).
|
||||
it('51 | Resolves a formula pasted into the primary key column to its computed value immediately', () => {
|
||||
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('"PRIMARY_KEY_FIELD":1000')
|
||||
}
|
||||
|
||||
req.continue()
|
||||
}).as('stpExecute')
|
||||
|
||||
getCellByHeaderAndRow(4, 'PRIMARY_KEY_FIELD').should('have.text', '5')
|
||||
|
||||
getCellByHeaderAndRow(4, 'PRIMARY_KEY_FIELD').click()
|
||||
|
||||
pasteTextIntoFocusedCell('=999+1')
|
||||
|
||||
getCellByHeaderAndRow(4, 'PRIMARY_KEY_FIELD').should(
|
||||
'have.text',
|
||||
'1000'
|
||||
)
|
||||
|
||||
submitTable()
|
||||
|
||||
cy.get('#submitBtn', { timeout: longerCommandTimeout })
|
||||
.should('exist')
|
||||
.should('not.be.disabled')
|
||||
|
||||
cy.get('#formFields_8').type(
|
||||
'primary key computed formula value submission test'
|
||||
)
|
||||
|
||||
submitTableMessage()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Nothing gates a HARDFORMULA/SOFTFORMULA rule's BASE_COL by column type
|
||||
// or PK-ness - MPE_X_FORMULA_PK_TEST's composite PK is itself made up of
|
||||
// a HARDFORMULA numeric column and a SOFTFORMULA character column. Left
|
||||
// unresolved, that reproduces the same PK-identity bug test 51 fixes -
|
||||
// just triggered by the rule seeding instead of a user edit, and present
|
||||
// on every row from the very first render rather than only an edited
|
||||
// one. resolvePkFormulaSeedValues (editor.component.ts) freezes both PK
|
||||
// cells to their computed values once, right after the initial table
|
||||
// load, so they display correctly immediately and the row still submits
|
||||
// - editing A_COL afterward (which the PK formulas reference) must NOT
|
||||
// retroactively recompute either PK cell, proving they're frozen rather
|
||||
// than still-live.
|
||||
it('52 | A primary key made of HARDFORMULA/SOFTFORMULA columns freezes to its computed value at load and still submits correctly', () => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_pk_test')
|
||||
|
||||
// Row 0: A_COL=2, B_COL=3 -> PK_HARDFORMULA_COL=6, PK_SOFTFORMULA_COL="PK-2".
|
||||
getCellByHeaderAndRow(0, 'PK_HARDFORMULA_COL').should('have.text', '6')
|
||||
getCellByHeaderAndRow(0, 'PK_SOFTFORMULA_COL').should('have.text', 'PK-2')
|
||||
|
||||
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')) {
|
||||
// The row must not be silently dropped from what's submitted
|
||||
// - both frozen PK values and the actually-edited column must
|
||||
// be present.
|
||||
expect(bodyText).to.include('"PK_HARDFORMULA_COL":6')
|
||||
expect(bodyText).to.include('"PK_SOFTFORMULA_COL":"PK-2"')
|
||||
expect(bodyText).to.include('"A_COL":9')
|
||||
}
|
||||
|
||||
req.continue()
|
||||
}).as('stpExecute')
|
||||
|
||||
getCellByHeaderAndRow(0, 'A_COL')
|
||||
.dblclick({ force: true })
|
||||
.then(() => {
|
||||
cy.focused().clear().type('9{enter}')
|
||||
})
|
||||
|
||||
// Frozen, not live - A_COL changing from 2 to 9 would recompute
|
||||
// PK_HARDFORMULA_COL to 27 if it were still a live formula.
|
||||
getCellByHeaderAndRow(0, 'PK_HARDFORMULA_COL').should('have.text', '6')
|
||||
|
||||
submitTable()
|
||||
|
||||
cy.get('#submitBtn', { timeout: longerCommandTimeout })
|
||||
.should('exist')
|
||||
.should('not.be.disabled')
|
||||
|
||||
cy.get('#formFields_8').type(
|
||||
'formula-driven composite primary key submission test'
|
||||
)
|
||||
|
||||
submitTableMessage()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Handsontable virtualizes columns — with 18 columns on MPE_X_NEW, only
|
||||
|
||||
@@ -1846,6 +1846,53 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A primary key identifies its own row for the rest of the edit session
|
||||
* (getRowKey, dataModified, classifyRow all match rows by comparing PK
|
||||
* values) - a HARDFORMULA/SOFTFORMULA rule on a PK column gets seeded by
|
||||
* applyFormulaRules as a live `=...` formula string exactly like any
|
||||
* other formula column, but a PK can never be allowed to keep
|
||||
* recalculating for the rest of the session the way a normal formula
|
||||
* column does: saveTable's own formulaRules loop only resolves a formula
|
||||
* column's computed value once, at submit, so anything that changed a
|
||||
* column the PK's formula depends on in between would silently shift the
|
||||
* PK out from under dataModified/classifyRow's PK-matching - the same
|
||||
* failure mode the afterChange hook below already prevents for a
|
||||
* user-typed PK formula. So a PK-as-formula-column is frozen to its
|
||||
* computed value once, right here at load. Both dataSource and
|
||||
* dataSourceUnchanged get the same frozen value - overriding whatever
|
||||
* overlayFormulaRawValuesOnUnchanged set for it, since a PK's identity
|
||||
* role takes priority over the normal "did a formula overwrite real
|
||||
* data" modified-detection every other formula column gets.
|
||||
*/
|
||||
private resolvePkFormulaSeedValues(): void {
|
||||
const hot = this.hotInstance
|
||||
const formulaBaseCols = getFormulaColumnNames(
|
||||
this.dcValidator?.getDqDetails() ?? []
|
||||
)
|
||||
const pkFormulaCols = this.headerPks.filter((pk) =>
|
||||
formulaBaseCols.includes(pk)
|
||||
)
|
||||
if (pkFormulaCols.length === 0) return
|
||||
|
||||
// Deferred for the same reason as this method's own caller
|
||||
// (markOverwrittenCells) defers its setDataAtRowProp call above: right
|
||||
// after the initial hot.updateSettings(), the Formulas plugin's
|
||||
// hidden-column index mapping hasn't settled yet, and setDataAtRowProp
|
||||
// throws ExpectedValueOfTypeError before it has.
|
||||
setTimeout(() => {
|
||||
this.dataSource.forEach((_row, rowIndex) => {
|
||||
for (const pkCol of pkFormulaCols) {
|
||||
const computed = hot.getDataAtRowProp(rowIndex, pkCol)
|
||||
hot.setDataAtRowProp(rowIndex, pkCol, computed, 'resolvePkFormula')
|
||||
|
||||
const unchangedRow = this.dataSourceUnchanged?.[rowIndex]
|
||||
if (unchangedRow) unchangedRow[pkCol] = computed
|
||||
}
|
||||
})
|
||||
}, 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,
|
||||
@@ -4144,6 +4191,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
// actually evaluated them against the data/formulas settings just
|
||||
// applied.
|
||||
this.markOverwrittenCells()
|
||||
this.resolvePkFormulaSeedValues()
|
||||
|
||||
this.hotTable.hidden = false
|
||||
// Keep the context menu enabled in view mode too so Copy/Export remain
|
||||
@@ -4448,6 +4496,41 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
return value
|
||||
})
|
||||
|
||||
// A primary key identifies its own row for the rest of the edit session
|
||||
// (getRowKey, dataModified, classifyRow all match rows by comparing PK
|
||||
// values) - if it's left holding a live formula, HyperFormula's
|
||||
// recalculation can shift that identity out from under those matches
|
||||
// between whenever the formula was entered and whenever saveTable()
|
||||
// finally resolves it. Unlike any other column, resolve a PK's formula
|
||||
// to its computed value immediately, right when it's entered (typed,
|
||||
// pasted, autofilled, or "Apply as formula"'d), so the PK is never
|
||||
// anything other than a stable, already-computed value going forward.
|
||||
// Deferred via setTimeout, same as the NOTNULL default-population hook
|
||||
// above, since writing back through setDataAtRowProp from inside
|
||||
// afterChange itself would re-enter change processing immediately.
|
||||
hot.addHook('afterChange', (changes: any[], source: any) => {
|
||||
if (!changes || source === 'loadData' || source === 'resolvePkFormula')
|
||||
return
|
||||
|
||||
for (const change of changes) {
|
||||
if (!change) continue
|
||||
|
||||
const [row, prop, , newValue] = change
|
||||
if (typeof newValue !== 'string' || !newValue.startsWith('=')) continue
|
||||
|
||||
const colName =
|
||||
typeof prop === 'string'
|
||||
? prop
|
||||
: (hot.colToProp(prop as number) as string)
|
||||
if (!this.headerPks.includes(colName)) continue
|
||||
|
||||
setTimeout(() => {
|
||||
const computed = hot.getDataAtRowProp(row, colName)
|
||||
hot.setDataAtRowProp(row, colName, computed, 'resolvePkFormula')
|
||||
}, 0)
|
||||
}
|
||||
})
|
||||
|
||||
hot.addHook('beforePaste', (data: any, cords: any) => {
|
||||
const startCol = cords[0].startCol
|
||||
const startRow = cords[0].startRow
|
||||
|
||||
@@ -689,6 +689,148 @@ describe('submission resolves a genuine live formula to its computed value, not
|
||||
|
||||
expect(dataSource[0].B_COL).toEqual(4)
|
||||
})
|
||||
|
||||
// HARDFORMULA/SOFTFORMULA rules aren't restricted to character columns
|
||||
// either - applyFormulaRules seeds the same live `=...` string into a
|
||||
// numeric column regardless of RULE_TYPE, and this resolve step (the
|
||||
// same one above) doesn't distinguish HARDFORMULA from SOFTFORMULA or
|
||||
// ad-hoc from rule-seeded - it just resolves whatever `=`-led text it
|
||||
// finds. A non-PK column is never used for row-matching, so this is
|
||||
// already the correct, complete fix - unlike a PK-as-formula-column
|
||||
// (see the freeze-at-load describe block below).
|
||||
it('submits the computed value for a HARDFORMULA-seeded numeric (non-PK) column', () => {
|
||||
const dataSource: any[] = [{ PRIMARY_KEY_FIELD: 1, B_COL: '=3+3' }]
|
||||
|
||||
runSubmitPrep(dataSource, ['PRIMARY_KEY_FIELD', 'B_COL'], new Map())
|
||||
|
||||
expect(dataSource[0].B_COL).toEqual(6)
|
||||
})
|
||||
|
||||
it('submits the computed value for a SOFTFORMULA-seeded numeric (non-PK) column', () => {
|
||||
const dataSource: any[] = [{ PRIMARY_KEY_FIELD: 1, B_COL: '=4+4' }]
|
||||
|
||||
runSubmitPrep(dataSource, ['PRIMARY_KEY_FIELD', 'B_COL'], new Map())
|
||||
|
||||
expect(dataSource[0].B_COL).toEqual(8)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* A primary key identifies its own row for the rest of the edit session -
|
||||
* getRowKey, dataModified (see saveTable) and classifyRow all match a row
|
||||
* up by comparing its current PK value against an earlier snapshot. A live
|
||||
* formula left sitting in a PK cell keeps recalculating right up until
|
||||
* submission resolves it, so the PK that gets matched against an earlier
|
||||
* snapshot can differ from the PK the row was snapshotted under -
|
||||
* silently dropping the row from what's submitted. Unlike any other
|
||||
* column (which stays live and is resolved only once, at submit time), a
|
||||
* PK cell resolves its formula to a plain computed value the moment that
|
||||
* edit completes - typed, pasted, autofilled, or "Apply as formula"'d -
|
||||
* exactly like editor.component.ts's own dedicated afterChange hook.
|
||||
* Mirrors that hook's resolve step against a real
|
||||
* Handsontable + HyperFormula instance, since proving a formula's
|
||||
* computed value requires a real engine.
|
||||
*/
|
||||
describe('a primary key resolves a live formula to its computed value as soon as editing completes', () => {
|
||||
const headerPks = ['PRIMARY_KEY_FIELD']
|
||||
|
||||
// Mirrors the afterChange hook's own resolve step, minus the setTimeout
|
||||
// deferral (an implementation detail there purely to avoid re-entering
|
||||
// change processing synchronously from inside afterChange itself - the
|
||||
// resolve logic it defers to is exactly this).
|
||||
const resolvePkFormulaIfNeeded = (
|
||||
hot: Handsontable,
|
||||
row: number,
|
||||
colName: string,
|
||||
newValue: any
|
||||
): void => {
|
||||
if (typeof newValue !== 'string' || !newValue.startsWith('=')) return
|
||||
if (!headerPks.includes(colName)) return
|
||||
|
||||
const computed = hot.getDataAtRowProp(row, colName)
|
||||
hot.setDataAtRowProp(row, colName, computed, 'resolvePkFormula')
|
||||
}
|
||||
|
||||
const setup = (dataSource: any[], colNames: string[]): Handsontable => {
|
||||
const hot = new Handsontable(document.createElement('div'), {
|
||||
data: dataSource,
|
||||
columns: colNames.map((c) => ({ data: c })),
|
||||
formulas: { engine: HyperFormula, licenseKey: 'gpl-v3' },
|
||||
licenseKey: 'non-commercial-and-evaluation'
|
||||
})
|
||||
hot.render()
|
||||
return hot
|
||||
}
|
||||
|
||||
it('resolves a formula typed/pasted/autofilled into a PK cell to its computed value once editing completes', () => {
|
||||
const dataSource: any[] = [{ PRIMARY_KEY_FIELD: 1, PLAIN_TEXT_COL: 'x' }]
|
||||
const hot = setup(dataSource, ['PRIMARY_KEY_FIELD', 'PLAIN_TEXT_COL'])
|
||||
|
||||
hot.setDataAtRowProp(0, 'PRIMARY_KEY_FIELD', '=2+3')
|
||||
resolvePkFormulaIfNeeded(hot, 0, 'PRIMARY_KEY_FIELD', '=2+3')
|
||||
|
||||
expect(dataSource[0].PRIMARY_KEY_FIELD).toEqual(5)
|
||||
hot.destroy()
|
||||
})
|
||||
|
||||
// The resolve step never checks the PK's own DDTYPE - only headerPks
|
||||
// membership - so a character-typed PK whose formula resolves to text
|
||||
// (not a number) is frozen exactly the same way as a numeric one.
|
||||
it('resolves a formula into a character-typed PK cell to its computed text value once editing completes', () => {
|
||||
const dataSource: any[] = [
|
||||
{ PRIMARY_KEY_FIELD: 'PK-1', PLAIN_TEXT_COL: 'x' }
|
||||
]
|
||||
const hot = setup(dataSource, ['PRIMARY_KEY_FIELD', 'PLAIN_TEXT_COL'])
|
||||
|
||||
hot.setDataAtRowProp(0, 'PRIMARY_KEY_FIELD', '="PK-" & 2')
|
||||
resolvePkFormulaIfNeeded(hot, 0, 'PRIMARY_KEY_FIELD', '="PK-" & 2')
|
||||
|
||||
expect(dataSource[0].PRIMARY_KEY_FIELD).toEqual('PK-2')
|
||||
hot.destroy()
|
||||
})
|
||||
|
||||
it('leaves a non-PK column formula alone - stays live, resolved only at submission like before', () => {
|
||||
const dataSource: any[] = [{ PRIMARY_KEY_FIELD: 1, PLAIN_TEXT_COL: 'x' }]
|
||||
const hot = setup(dataSource, ['PRIMARY_KEY_FIELD', 'PLAIN_TEXT_COL'])
|
||||
|
||||
hot.setDataAtRowProp(0, 'PLAIN_TEXT_COL', '=2+3')
|
||||
resolvePkFormulaIfNeeded(hot, 0, 'PLAIN_TEXT_COL', '=2+3')
|
||||
|
||||
expect(dataSource[0].PLAIN_TEXT_COL).toEqual('=2+3')
|
||||
hot.destroy()
|
||||
})
|
||||
|
||||
it('leaves a genuinely numeric edit into a PK column untouched - not treated as a formula', () => {
|
||||
const dataSource: any[] = [{ PRIMARY_KEY_FIELD: 1, PLAIN_TEXT_COL: 'x' }]
|
||||
const hot = setup(dataSource, ['PRIMARY_KEY_FIELD', 'PLAIN_TEXT_COL'])
|
||||
|
||||
hot.setDataAtRowProp(0, 'PRIMARY_KEY_FIELD', 5)
|
||||
resolvePkFormulaIfNeeded(hot, 0, 'PRIMARY_KEY_FIELD', 5)
|
||||
|
||||
expect(dataSource[0].PRIMARY_KEY_FIELD).toEqual(5)
|
||||
hot.destroy()
|
||||
})
|
||||
|
||||
it('a PK resolved eagerly on entry stays matchable against an earlier dataModified snapshot at submit time', () => {
|
||||
// The PK is already a plain computed value by the time anything
|
||||
// snapshots it, so dataModified's PK-matching never sees it change
|
||||
// out from under itself.
|
||||
const dataSource: any[] = [{ PRIMARY_KEY_FIELD: 1, PLAIN_TEXT_COL: 'x' }]
|
||||
const hot = setup(dataSource, ['PRIMARY_KEY_FIELD', 'PLAIN_TEXT_COL'])
|
||||
|
||||
hot.setDataAtRowProp(0, 'PRIMARY_KEY_FIELD', '=2+3')
|
||||
resolvePkFormulaIfNeeded(hot, 0, 'PRIMARY_KEY_FIELD', '=2+3')
|
||||
|
||||
const dataModified: any[] = [
|
||||
{ PRIMARY_KEY_FIELD: dataSource[0].PRIMARY_KEY_FIELD }
|
||||
]
|
||||
const stillMatches = dataModified.some((row) =>
|
||||
headerPks.every((pk) => row[pk] === dataSource[0][pk])
|
||||
)
|
||||
|
||||
expect(stillMatches).toEqual(true)
|
||||
hot.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
const submitStrip = (
|
||||
@@ -705,3 +847,102 @@ const submitStrip = (
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nothing gates a HARDFORMULA/SOFTFORMULA rule's BASE_COL by column type or
|
||||
* PK-ness, so a primary key can itself be one - applyFormulaRules seeds it
|
||||
* with a live `=...` formula string at load, exactly like any other
|
||||
* formula column. Left alone, that reproduces the exact bug the
|
||||
* user-entered-PK-formula fix above solves, just triggered by the rule
|
||||
* seeding instead of a user edit: saveTable's formulaRules loop only
|
||||
* resolves it once, at submit, so anything that changed a column the PK's
|
||||
* formula depends on in between would silently shift dataModified's
|
||||
* PK-matching out from under itself - and, unlike a user-entered formula,
|
||||
* a rule-seeded one is ALWAYS present in dataSource, from the very first
|
||||
* render, so it's not a rare edge case at all. resolvePkFormulaSeedValues
|
||||
* (editor.component.ts, called once right after the initial
|
||||
* hot.updateSettings()) freezes it to its computed value immediately,
|
||||
* mirrored here against a real Handsontable + HyperFormula instance -
|
||||
* writing the same frozen value onto both dataSource and
|
||||
* dataSourceUnchanged (deliberately overriding whatever
|
||||
* overlayFormulaRawValuesOnUnchanged put there), since a PK must agree
|
||||
* with itself on both sides for classifyRow's own PK-based lookup to ever
|
||||
* find its row again.
|
||||
*/
|
||||
describe('a primary key configured as a HARDFORMULA/SOFTFORMULA column freezes to its computed value at load', () => {
|
||||
const resolvePkFormulaSeedValues = (
|
||||
hot: Handsontable,
|
||||
dataSource: any[],
|
||||
dataSourceUnchanged: any[],
|
||||
pkFormulaCols: string[]
|
||||
): void => {
|
||||
dataSource.forEach((_row, rowIndex) => {
|
||||
for (const pkCol of pkFormulaCols) {
|
||||
const computed = hot.getDataAtRowProp(rowIndex, pkCol)
|
||||
hot.setDataAtRowProp(rowIndex, pkCol, computed, 'resolvePkFormula')
|
||||
|
||||
const unchangedRow = dataSourceUnchanged[rowIndex]
|
||||
if (unchangedRow) unchangedRow[pkCol] = computed
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('freezes a HARDFORMULA-driven numeric PK to its computed value on both dataSource and dataSourceUnchanged', () => {
|
||||
// applyFormulaRules seeds dataSource and dataSourceUnchanged
|
||||
// identically at load - before anything has diverged them yet.
|
||||
const dataSource: any[] = [{ PRIMARY_KEY_FIELD: '=2+3', A_COL: 1 }]
|
||||
const dataSourceUnchanged: any[] = [{ PRIMARY_KEY_FIELD: '=2+3', A_COL: 1 }]
|
||||
|
||||
const hot = new Handsontable(document.createElement('div'), {
|
||||
data: dataSource,
|
||||
columns: ['PRIMARY_KEY_FIELD', 'A_COL'].map((c) => ({ data: c })),
|
||||
formulas: { engine: HyperFormula, licenseKey: 'gpl-v3' },
|
||||
licenseKey: 'non-commercial-and-evaluation'
|
||||
})
|
||||
hot.render()
|
||||
|
||||
resolvePkFormulaSeedValues(hot, dataSource, dataSourceUnchanged, [
|
||||
'PRIMARY_KEY_FIELD'
|
||||
])
|
||||
|
||||
expect(dataSource[0].PRIMARY_KEY_FIELD).toEqual(5)
|
||||
expect(dataSourceUnchanged[0].PRIMARY_KEY_FIELD).toEqual(5)
|
||||
|
||||
// classifyRow's own PK-based lookup - both sides now agree.
|
||||
const found = dataSourceUnchanged.find((row) =>
|
||||
['PRIMARY_KEY_FIELD'].every((pk) => row[pk] === dataSource[0][pk])
|
||||
)
|
||||
expect(found).toBeTruthy()
|
||||
|
||||
hot.destroy()
|
||||
})
|
||||
|
||||
it('freezes a SOFTFORMULA-driven character PK to its computed value on both dataSource and dataSourceUnchanged', () => {
|
||||
const dataSource: any[] = [{ PRIMARY_KEY_FIELD: '="PK-" & 2', A_COL: 1 }]
|
||||
const dataSourceUnchanged: any[] = [
|
||||
{ PRIMARY_KEY_FIELD: '="PK-" & 2', A_COL: 1 }
|
||||
]
|
||||
|
||||
const hot = new Handsontable(document.createElement('div'), {
|
||||
data: dataSource,
|
||||
columns: ['PRIMARY_KEY_FIELD', 'A_COL'].map((c) => ({ data: c })),
|
||||
formulas: { engine: HyperFormula, licenseKey: 'gpl-v3' },
|
||||
licenseKey: 'non-commercial-and-evaluation'
|
||||
})
|
||||
hot.render()
|
||||
|
||||
resolvePkFormulaSeedValues(hot, dataSource, dataSourceUnchanged, [
|
||||
'PRIMARY_KEY_FIELD'
|
||||
])
|
||||
|
||||
expect(dataSource[0].PRIMARY_KEY_FIELD).toEqual('PK-2')
|
||||
expect(dataSourceUnchanged[0].PRIMARY_KEY_FIELD).toEqual('PK-2')
|
||||
|
||||
const found = dataSourceUnchanged.find((row) =>
|
||||
['PRIMARY_KEY_FIELD'].every((pk) => row[pk] === dataSource[0][pk])
|
||||
)
|
||||
expect(found).toBeTruthy()
|
||||
|
||||
hot.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1764,6 +1764,148 @@ let webouts = {
|
||||
SYSWARNINGTEXT: "",
|
||||
END_DTTM: "2026-07-28T12:00:00.000000",
|
||||
MEMSIZE: "1MB"
|
||||
},
|
||||
// Nothing gates a HARDFORMULA/SOFTFORMULA rule's BASE_COL by column
|
||||
// type or PK-ness - this table's composite PK is itself made up of
|
||||
// two formula-driven columns (a HARDFORMULA numeric one, a
|
||||
// SOFTFORMULA character one), isolating that a PK-as-formula-column
|
||||
// freezes to its computed value at load and the row still submits
|
||||
// correctly, rather than silently dropping out (see
|
||||
// resolvePkFormulaSeedValues in editor.component.ts).
|
||||
MPE_X_FORMULA_PK_TEST: {
|
||||
SYSDATE: "26AUG26",
|
||||
SYSTIME: "12:00",
|
||||
approvers: [],
|
||||
cols: [
|
||||
{
|
||||
NAME: "PK_HARDFORMULA_COL",
|
||||
LABEL: "PK_HARDFORMULA_COL",
|
||||
FMTNAME: "",
|
||||
DDTYPE: "N",
|
||||
CLS_RULE: "READ",
|
||||
MEMLABEL: "",
|
||||
DESC: "HARDFORMULA: computed A_COL * B_COL, also this table's first PK column",
|
||||
LONGDESC: "",
|
||||
COLTYPE: "{\"data\":\"PK_HARDFORMULA_COL\",\"type\":\"numeric\",\"format\":\"0\"}"
|
||||
},
|
||||
{
|
||||
NAME: "PK_SOFTFORMULA_COL",
|
||||
LABEL: "PK_SOFTFORMULA_COL",
|
||||
FMTNAME: "",
|
||||
DDTYPE: "C",
|
||||
CLS_RULE: "READ",
|
||||
MEMLABEL: "",
|
||||
DESC: "SOFTFORMULA: computed \"PK-\" & A_COL, also this table's second PK column",
|
||||
LONGDESC: "",
|
||||
COLTYPE: "{\"data\":\"PK_SOFTFORMULA_COL\"}"
|
||||
},
|
||||
{
|
||||
NAME: "A_COL",
|
||||
LABEL: "A_COL",
|
||||
FMTNAME: "",
|
||||
DDTYPE: "N",
|
||||
CLS_RULE: "READ",
|
||||
MEMLABEL: "",
|
||||
DESC: "",
|
||||
LONGDESC: "",
|
||||
COLTYPE: "{\"data\":\"A_COL\",\"type\":\"numeric\",\"format\":\"0\"}"
|
||||
},
|
||||
{
|
||||
NAME: "B_COL",
|
||||
LABEL: "B_COL",
|
||||
FMTNAME: "",
|
||||
DDTYPE: "N",
|
||||
CLS_RULE: "READ",
|
||||
MEMLABEL: "",
|
||||
DESC: "",
|
||||
LONGDESC: "",
|
||||
COLTYPE: "{\"data\":\"B_COL\",\"type\":\"numeric\",\"format\":\"0\"}"
|
||||
}
|
||||
],
|
||||
dqdata: [],
|
||||
dqrules: [
|
||||
{ BASE_COL: "PK_HARDFORMULA_COL", RULE_TYPE: "HARDFORMULA", RULE_VALUE: "=A_COL * B_COL" },
|
||||
{ BASE_COL: "PK_SOFTFORMULA_COL", RULE_TYPE: "SOFTFORMULA", RULE_VALUE: "=\"PK-\" & A_COL" }
|
||||
],
|
||||
dsmeta: [
|
||||
{ ODS_TABLE: "ATTRIBUTES", NAME: "Data Set Name", VALUE: "MPE_X_FORMULA_PK_TEST" },
|
||||
{ ODS_TABLE: "ATTRIBUTES", NAME: "Member Type", VALUE: "DATA" },
|
||||
{ ODS_TABLE: "ATTRIBUTES", NAME: "Engine", VALUE: "V9" },
|
||||
{ ODS_TABLE: "ATTRIBUTES", NAME: "Observations", VALUE: "2" },
|
||||
{ ODS_TABLE: "ATTRIBUTES", NAME: "Variables", VALUE: "4" }
|
||||
],
|
||||
maxvarlengths: [
|
||||
{ NAME: "_____DELETE__THIS__RECORD_____", MAXLEN: 3 },
|
||||
{ NAME: "pk_hardformula_col", MAXLEN: 8 },
|
||||
{ NAME: "pk_softformula_col", MAXLEN: 128 },
|
||||
{ NAME: "a_col", MAXLEN: 8 },
|
||||
{ NAME: "b_col", MAXLEN: 8 }
|
||||
],
|
||||
query: [],
|
||||
// Row 1: A_COL=2, B_COL=3 -> PK_HARDFORMULA_COL=6, PK_SOFTFORMULA_COL="PK-2".
|
||||
// Row 2: A_COL=5, B_COL=4 -> PK_HARDFORMULA_COL=20, PK_SOFTFORMULA_COL="PK-5".
|
||||
// Both PK columns' seed values below are irrelevant - applyFormulaRules
|
||||
// unconditionally overwrites every formula-rule column at load, same as
|
||||
// MPE_X_FORMULA_TEST's own FORMULA_HARD_COL/FORMULA_SOFT_COL.
|
||||
sasdata: [
|
||||
{
|
||||
_____DELETE__THIS__RECORD_____: "No",
|
||||
PK_HARDFORMULA_COL: "",
|
||||
PK_SOFTFORMULA_COL: "",
|
||||
A_COL: 2,
|
||||
B_COL: 3
|
||||
},
|
||||
{
|
||||
_____DELETE__THIS__RECORD_____: "No",
|
||||
PK_HARDFORMULA_COL: "",
|
||||
PK_SOFTFORMULA_COL: "",
|
||||
A_COL: 5,
|
||||
B_COL: 4
|
||||
}
|
||||
],
|
||||
$sasdata: {
|
||||
vars: {
|
||||
_____DELETE__THIS__RECORD_____: { format: "$3.", label: "_____DELETE__THIS__RECORD_____", length: "3", type: "char" },
|
||||
PK_HARDFORMULA_COL: { format: "best.", label: "PK_HARDFORMULA_COL", length: "8", type: "num" },
|
||||
PK_SOFTFORMULA_COL: { format: "$128.", label: "PK_SOFTFORMULA_COL", length: "128", type: "char" },
|
||||
A_COL: { format: "best.", label: "A_COL", length: "8", type: "num" },
|
||||
B_COL: { format: "best.", label: "B_COL", length: "8", type: "num" }
|
||||
}
|
||||
},
|
||||
sasparams: [
|
||||
{
|
||||
COLHEADERS: "_____DELETE__THIS__RECORD_____,PK_HARDFORMULA_COL,PK_SOFTFORMULA_COL,A_COL,B_COL",
|
||||
FILTER_TEXT: "",
|
||||
PKCNT: 2,
|
||||
PK: "PK_HARDFORMULA_COL PK_SOFTFORMULA_COL",
|
||||
DTVARS: "",
|
||||
DTTMVARS: "",
|
||||
TMVARS: "",
|
||||
LOADTYPE: "UPDATE",
|
||||
RK_FLAG: 0,
|
||||
CLS_FLAG: 0
|
||||
}
|
||||
],
|
||||
xl_rules: [],
|
||||
_DEBUG: "",
|
||||
_PROGRAM: "/Public/app/dc/services/editors/getdata",
|
||||
AUTOEXEC: "",
|
||||
MF_GETUSER: "sasdemo",
|
||||
SYSCC: "0",
|
||||
SYSENCODING: "utf-8",
|
||||
SYSERRORTEXT: "",
|
||||
SYSHOSTNAME: "SAS",
|
||||
SYSPROCESSID: "0",
|
||||
SYSPROCESSMODE: "SAS Batch Mode",
|
||||
SYSPROCESSNAME: "",
|
||||
SYSJOBID: "1",
|
||||
SYSSCPL: "Linux",
|
||||
SYSSITE: "123",
|
||||
SYSUSERID: "sasjssrv",
|
||||
SYSVLONG: "9.04.01M7P080520",
|
||||
SYSWARNINGTEXT: "",
|
||||
END_DTTM: "2026-08-26T12:00:00.000000",
|
||||
MEMSIZE: "1MB"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1802,6 +1944,8 @@ if (_WEBIN_FILEREF1) {
|
||||
|
||||
if (file1.includes('MPE_X_NEW')) {
|
||||
table = 'MPE_X_NEW'
|
||||
} else if (file1.includes('MPE_X_FORMULA_PK_TEST')) {
|
||||
table = 'MPE_X_FORMULA_PK_TEST'
|
||||
} else if (file1.includes('MPE_X_FORMULA_TEST')) {
|
||||
table = 'MPE_X_FORMULA_TEST'
|
||||
} else if (file1.includes('MPE_X_TEST')) {
|
||||
|
||||
@@ -59,6 +59,10 @@ _webout = `{"SYSDATE" : "26SEP22"
|
||||
"LIBREF": "DC996664",
|
||||
"DSN": "MPE_X_FORMULA_TEST"
|
||||
},
|
||||
{
|
||||
"LIBREF": "DC996664",
|
||||
"DSN": "MPE_X_FORMULA_PK_TEST"
|
||||
},
|
||||
{
|
||||
"LIBREF": "DC996664",
|
||||
"DSN": "MPE_DATADICTIONARY"
|
||||
|
||||
Reference in New Issue
Block a user