Compare commits

...
Author SHA1 Message Date
YuryShkoda 1e516f4012 fix(editor): let the column info dropdown's text be selected and copied
Build / Build-and-ng-test (pull_request) Successful in 5m24s
Lighthouse Checks / lighthouse (pull_request) Successful in 23m1s
Build / Build-and-test-development (pull_request) Successful in 23m23s
Handsontable's Menu widget closes on any mouseup inside an item and
unconditionally preventDefault()s contextmenu, even for the info item,
which has no callback and exists purely to show read-only column details.
That made its text impossible to select or right-click-copy. Marked the
item isCommand: false and stop mousedown/mouseup/contextmenu/selectstart
from bubbling past its rendered content to the menu's own listeners.
2026-08-06 14:48:30 +03:00
YuryShkoda d0a7561f1a fix(editor): size table-header buttons and title to content, not fixed grid thirds
Build / Build-and-ng-test (pull_request) Successful in 7m24s
Lighthouse Checks / lighthouse (pull_request) Successful in 23m26s
Build / Build-and-test-development (pull_request) Successful in 22m52s
The Filter/Edit/Upload buttons used btn-block (width: 100%), stretching
them wider than the Cancel/Add Row/Submit buttons shown during edit.
The back/viewboxes, title, and action-button columns were also locked to
equal 12-col grid thirds regardless of actual content, so the dataset
name/row-count wrapped even with visible free space on either side.
Switched the outer columns to size to their content (clr-col-*-auto) and
let the title column flex-grow into whatever space is left.
2026-08-06 13:16:08 +03:00
YuryShkoda 07d586da52 feat(editor): generalize cell revert to any overwritten value, not just formulas
Build / Build-and-ng-test (pull_request) Successful in 5m7s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m7s
Build / Build-and-test-development (pull_request) Successful in 23m29s
2026-08-06 12:47:41 +03:00
16 changed files with 1166 additions and 240 deletions
+320 -11
View File
@@ -856,19 +856,19 @@ context('editor tests: ', function () {
.and('have.class', 'htCommentCell') .and('have.class', 'htCommentCell')
.rightclick({ force: true }) .rightclick({ force: true })
// Only our own "Revert value" is offered - never the Comments // Only our own "Revert" is offered - never the Comments plugin's
// plugin's own add/edit/delete items, since comments here are // own add/edit/delete items, since comments here are strictly
// strictly programmatic (see the comments: {readOnly: true} // programmatic (see the comments: {readOnly: true} setting and the
// setting and the deliberately curated contextMenu.items list). // deliberately curated contextMenu.items list).
cy.get('.htContextMenu').should(($menu) => { cy.get('.htContextMenu').should(($menu) => {
const text = $menu.text() const text = $menu.text()
expect(text).to.include('Revert value') expect(text).to.include('Revert')
expect(text).not.to.include('Add comment') expect(text).not.to.include('Add comment')
expect(text).not.to.include('Edit comment') expect(text).not.to.include('Edit comment')
expect(text).not.to.include('Delete comment') expect(text).not.to.include('Delete comment')
}) })
cy.get('.htContextMenu').contains('Revert value').click() cy.get('.htContextMenu').contains('Revert').click()
getCellByHeaderAndRow(9, 'FORMULA_SOFT_COL') getCellByHeaderAndRow(9, 'FORMULA_SOFT_COL')
.should('have.text', '2222') .should('have.text', '2222')
@@ -882,9 +882,8 @@ context('editor tests: ', function () {
// modified (see test 28) - but that's not a session edit for Cancel to // modified (see test 28) - but that's not a session edit for Cancel to
// discard: the formula recomputes on every load regardless of what's // discard: the formula recomputes on every load regardless of what's
// sitting in dataSourceUnchanged. Cancelling (without ever touching // sitting in dataSourceUnchanged. Cancelling (without ever touching
// "Revert value") must keep showing the live computed value and the // "Revert") must keep showing the live computed value and the modified
// modified marker, both in the cancelled edit session and back in // marker, both in the cancelled edit session and back in read-only view.
// read-only view.
it('29 | Cancelling an edit session after a formula silently overwrote real data keeps the computed value and modified marker, not the stale raw one', () => { it('29 | Cancelling an edit session after a formula silently overwrote real data keeps the computed value and modified marker, not the stale raw one', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test') openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
@@ -939,7 +938,7 @@ context('editor tests: ', function () {
getCellByHeaderAndRow(9, 'FORMULA_SOFT_COL').rightclick({ getCellByHeaderAndRow(9, 'FORMULA_SOFT_COL').rightclick({
force: true force: true
}) })
cy.get('.htContextMenu').contains('Revert value').click() cy.get('.htContextMenu').contains('Revert').click()
getCellByHeaderAndRow(9, 'FORMULA_SOFT_COL') getCellByHeaderAndRow(9, 'FORMULA_SOFT_COL')
.should('have.text', '2222') .should('have.text', '2222')
@@ -974,6 +973,316 @@ context('editor tests: ', function () {
}) })
}) })
}) })
// Generalized revert: any cell whose value differs from what SAS
// actually sent, not only formula columns - A_COL is a plain numeric
// column with no HARDFORMULA/SOFTFORMULA rule of its own.
it('32 | Editing a plain (non-formula) cell marks it overwritten, and Revert restores it', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getCellByHeaderAndRow(1, 'A_COL')
.should('have.text', '2')
.and('not.have.class', 'htCommentCell')
.dblclick({ force: true })
.then(() => {
cy.focused().clear().type('999{enter}')
})
getCellByHeaderAndRow(1, 'A_COL')
.should('have.text', '999')
.and('have.class', 'htCommentCell')
.rightclick({ force: true })
cy.get('.htContextMenu').contains('Revert').click()
getCellByHeaderAndRow(1, 'A_COL')
.should('have.text', '2')
.and('not.have.class', 'htCommentCell')
})
})
})
it('33 | Selecting a range containing one overwritten cell shows Revert and only reverts that cell', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getCellByHeaderAndRow(2, 'A_COL')
.dblclick({ force: true })
.then(() => {
cy.focused().clear().type('999{enter}')
})
getCellByHeaderAndRow(2, 'A_COL').should('have.class', 'htCommentCell')
// Select the whole row's cell range (PRIMARY_KEY_FIELD through
// CHANGE_SUMMARY_COL) via shift-click, covering both the
// overwritten A_COL and several untouched cells.
getCellByHeaderAndRow(2, 'PRIMARY_KEY_FIELD').click({ force: true })
getCellByHeaderAndRow(2, 'CHANGE_SUMMARY_COL').click({
force: true,
shiftKey: true
})
getCellByHeaderAndRow(2, 'B_COL').rightclick({ force: true })
cy.get('.htContextMenu').contains('Revert').click()
getCellByHeaderAndRow(2, 'A_COL')
.should('have.text', '3')
.and('not.have.class', 'htCommentCell')
// An untouched cell within the same reverted selection is left
// exactly as it was - proves Revert only acts on the cell(s) that
// were actually overwritten, not the whole selection.
getCellByHeaderAndRow(2, 'B_COL').should('have.text', '10')
})
})
})
it('34 | Selecting a range with no overwritten cells does not show Revert', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getCellByHeaderAndRow(3, 'PRIMARY_KEY_FIELD').click({ force: true })
getCellByHeaderAndRow(3, 'CHANGE_SUMMARY_COL').click({
force: true,
shiftKey: true
})
getCellByHeaderAndRow(3, 'B_COL').rightclick({ force: true })
cy.get('.htContextMenu').should(($menu) => {
expect($menu.text()).not.to.include('Revert')
})
})
})
})
it('35 | A selection spanning a newly-inserted row and an existing overwritten cell only reverts the existing row', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getCellByHeaderAndRow(0, 'A_COL')
.dblclick({ force: true })
.then(() => {
cy.focused().clear().type('999{enter}')
})
getCellByHeaderAndRow(0, 'A_COL').should('have.class', 'htCommentCell')
insertRowViaContextMenu(0, 'Insert Row below')
// New row lands at index 1 - select a range spanning both the
// existing overwritten row (0) and the brand-new row (1).
getCellByHeaderAndRow(0, 'A_COL').click({ force: true })
getCellByHeaderAndRow(1, 'A_COL').click({
force: true,
shiftKey: true
})
getCellByHeaderAndRow(0, 'A_COL').rightclick({ force: true })
cy.get('.htContextMenu').contains('Revert').click()
getCellByHeaderAndRow(0, 'A_COL')
.should('have.text', '1')
.and('not.have.class', 'htCommentCell')
// The new row has no original SAS value to revert to - it's simply
// left alone, no error.
getCellByHeaderAndRow(1, 'A_COL').should('have.text', '')
})
})
})
// Rows 7-9 (0-indexed 6-8) are seeded with a staggered pattern (see the
// mock's own comment): row 7 only overwrites FORMULA_HARD_COL, row 8
// only overwrites FORMULA_SOFT_COL, row 9 overwrites both - all three
// already show their raw-vs-computed mismatch on load, with no manual
// edit needed.
it('36 | Selecting a block spanning multiple pre-loaded overwritten cells across different rows and columns reverts only the actually-overwritten ones', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getCellByHeaderAndRow(6, 'FORMULA_HARD_COL')
.should('have.text', '70')
.and('have.class', 'htCommentCell')
getCellByHeaderAndRow(7, 'FORMULA_SOFT_COL')
.should('have.text', '18')
.and('have.class', 'htCommentCell')
getCellByHeaderAndRow(8, 'FORMULA_HARD_COL')
.should('have.text', '90')
.and('have.class', 'htCommentCell')
getCellByHeaderAndRow(8, 'FORMULA_SOFT_COL')
.should('have.text', '19')
.and('have.class', 'htCommentCell')
// Select the 3-row x 2-col block covering rows 6-8, columns
// FORMULA_HARD_COL through FORMULA_SOFT_COL.
getCellByHeaderAndRow(6, 'FORMULA_HARD_COL').click({ force: true })
getCellByHeaderAndRow(8, 'FORMULA_SOFT_COL').click({
force: true,
shiftKey: true
})
getCellByHeaderAndRow(7, 'FORMULA_HARD_COL').rightclick({
force: true
})
cy.get('.htContextMenu').contains('Revert').click()
getCellByHeaderAndRow(6, 'FORMULA_HARD_COL')
.should('have.text', '7771')
.and('not.have.class', 'htCommentCell')
getCellByHeaderAndRow(7, 'FORMULA_SOFT_COL')
.should('have.text', '8882')
.and('not.have.class', 'htCommentCell')
getCellByHeaderAndRow(8, 'FORMULA_HARD_COL')
.should('have.text', '9991')
.and('not.have.class', 'htCommentCell')
getCellByHeaderAndRow(8, 'FORMULA_SOFT_COL')
.should('have.text', '9992')
.and('not.have.class', 'htCommentCell')
// Cells within the same block that were never overwritten in the
// first place are left exactly as they were computed.
getCellByHeaderAndRow(6, 'FORMULA_SOFT_COL')
.should('have.text', '17')
.and('not.have.class', 'htCommentCell')
getCellByHeaderAndRow(7, 'FORMULA_HARD_COL')
.should('have.text', '80')
.and('not.have.class', 'htCommentCell')
})
})
})
it('37 | Selecting an entire column reverts every pre-loaded overwritten cell in it, leaving other columns and out-of-range rows untouched', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
// Select every row of FORMULA_HARD_COL only - rows 6, 8 and 9 are
// overwritten in it, rows 0-5 and 7 are not.
getCellByHeaderAndRow(0, 'FORMULA_HARD_COL').click({ force: true })
getCellByHeaderAndRow(9, 'FORMULA_HARD_COL').click({
force: true,
shiftKey: true
})
getCellByHeaderAndRow(3, 'FORMULA_HARD_COL').rightclick({
force: true
})
cy.get('.htContextMenu').contains('Revert').click()
getCellByHeaderAndRow(6, 'FORMULA_HARD_COL')
.should('have.text', '7771')
.and('not.have.class', 'htCommentCell')
getCellByHeaderAndRow(8, 'FORMULA_HARD_COL')
.should('have.text', '9991')
.and('not.have.class', 'htCommentCell')
getCellByHeaderAndRow(9, 'FORMULA_HARD_COL')
.should('have.text', '1111')
.and('not.have.class', 'htCommentCell')
// A row that was never overwritten in FORMULA_HARD_COL is left as
// its computed value.
getCellByHeaderAndRow(2, 'FORMULA_HARD_COL')
.should('have.text', '30')
.and('not.have.class', 'htCommentCell')
// FORMULA_SOFT_COL was never part of the selection - row 7's
// pre-loaded overwrite there must survive untouched. It's a live
// formula cell, so it still displays the computed value (18), not
// the raw seed (8882) - the comment is what carries the raw value,
// not the display text (see test 36's identical assertion).
getCellByHeaderAndRow(7, 'FORMULA_SOFT_COL')
.should('have.text', '18')
.and('have.class', 'htCommentCell')
})
})
})
// PLAIN_TEXT_COL has no DQ rule at all (not HARDFORMULA/SOFTFORMULA, not
// even NOTNULL) - unlike FORMULA_HARD_COL/FORMULA_SOFT_COL it can never be
// pre-loaded as already overwritten (nothing mutates a value between the
// dataSourceRaw snapshot and first render except a formula rule), so this
// proves the general revert path still works via a plain live edit on a
// column that's a string, not a number.
it('38 | Editing a plain character column with no DQ rule at all marks it overwritten, and Revert restores it', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getCellByHeaderAndRow(1, 'PLAIN_TEXT_COL')
.should('have.text', 'note-2')
.and('not.have.class', 'htCommentCell')
.dblclick({ force: true })
.then(() => {
cy.focused().clear().type('edited by user{enter}')
})
getCellByHeaderAndRow(1, 'PLAIN_TEXT_COL')
.should('have.text', 'edited by user')
.and('have.class', 'htCommentCell')
.rightclick({ force: true })
cy.get('.htContextMenu').contains('Revert').click()
getCellByHeaderAndRow(1, 'PLAIN_TEXT_COL')
.should('have.text', 'note-2')
.and('not.have.class', 'htCommentCell')
})
})
})
// The info item has no callback (it's read-only display text), but
// Handsontable's Menu widget still treated any click landing inside it as
// "the item was activated" - closing the menu on left-click and, on
// right-click, preventDefault()-ing before the browser's own "Copy" menu
// could appear. Either way the user could never select/copy the text. If
// the dropdown were still closing, `.htDropdownMenu` would no longer
// exist and the `.should()` below would time out.
it('39 | Left-click or right-click inside the info dropdown does not close it', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
openColumnDropdown('FORMULA_HARD_COL')
cy.get('.htDropdownMenu').should(($menu) => {
expect($menu.text()).to.include('NAME: FORMULA_HARD_COL')
})
cy.get('.htDropdownMenu').contains('NAME:').click()
cy.get('.htDropdownMenu').should(($menu) => {
expect($menu.text()).to.include('NAME: FORMULA_HARD_COL')
})
cy.get('.htDropdownMenu').contains('NAME:').rightclick()
cy.get('.htDropdownMenu').should(($menu) => {
expect($menu.text()).to.include('NAME: FORMULA_HARD_COL')
})
})
})
})
}) })
// Handsontable virtualizes columns — with 18 columns on MPE_X_NEW, only // Handsontable virtualizes columns — with 18 columns on MPE_X_NEW, only
@@ -1017,7 +1326,7 @@ const openColumnDropdown = (headerText: string) => {
.last() .last()
.as('targetHeader') .as('targetHeader')
cy.get('@targetHeader').click() cy.get('@targetHeader').click({ force: true })
cy.get('@targetHeader').find('button.changeType').click({ force: true }) cy.get('@targetHeader').find('button.changeType').click({ force: true })
} }
+6 -6
View File
@@ -170,7 +170,7 @@
class="card-header clr-row buttonBar headerBar clr-flex-md-row clr-justify-content-center clr-justify-content-lg-end" class="card-header clr-row buttonBar headerBar clr-flex-md-row clr-justify-content-center clr-justify-content-lg-end"
> >
@if (tableTrue && !embed) { @if (tableTrue && !embed) {
<div class="clr-col-12 clr-col-md-3 clr-col-lg-4 backBtn"> <div class="clr-col-12 clr-col-md-auto clr-col-lg-auto backBtn">
<span <span
class="btn icon-collapse btn-sm btn-icon btn-dimmed" class="btn icon-collapse btn-sm btn-icon btn-dimmed"
[routerLink]="['/home']" [routerLink]="['/home']"
@@ -198,7 +198,7 @@
} }
<div <div
class="clr-col-12 clr-col-md-5 clr-col-lg-4 d-flex flex-column align-items-center" class="clr-col-12 clr-col-md clr-col-lg d-flex flex-column align-items-center"
[class.clr-col-lg-12]="!tableTrue" [class.clr-col-lg-12]="!tableTrue"
> >
<h4 <h4
@@ -258,12 +258,12 @@
</h4> </h4>
</div> </div>
@if (tableTrue) { @if (tableTrue) {
<div class="clr-col-12 clr-col-md-4 clr-col-lg-4 btnCtrl"> <div class="clr-col-12 clr-col-md-auto clr-col-lg-auto btnCtrl">
@if (hotTable.readOnly && !uploadPreview) { @if (hotTable.readOnly && !uploadPreview) {
@if (!isVaEmbed) { @if (!isVaEmbed) {
<button <button
type="button" type="button"
class="btnView btn icon-collapse btn-sm btn-icon btn-block btn-dimmed" class="btnView btn icon-collapse btn-sm btn-icon btn-dimmed"
(click)="openQb()" (click)="openQb()"
> >
<clr-icon aria-hidden="true" shape="filter"></clr-icon> <clr-icon aria-hidden="true" shape="filter"></clr-icon>
@@ -272,7 +272,7 @@
} }
<button <button
type="button" type="button"
class="btn icon-collapse btn-sm btn-primary btn-block" class="btn icon-collapse btn-sm btn-primary"
(click)="editTable()" (click)="editTable()"
> >
<clr-icon aria-hidden="true" shape="note"></clr-icon> <clr-icon aria-hidden="true" shape="note"></clr-icon>
@@ -282,7 +282,7 @@
<button <button
(click)="onShowUploadModal()" (click)="onShowUploadModal()"
type="button" type="button"
class="btn icon-collapse btn-sm btn-success btn-block mr-0" class="btn icon-collapse btn-sm btn-success mr-0"
> >
<clr-icon aria-hidden="true" shape="upload"></clr-icon> <clr-icon aria-hidden="true" shape="upload"></clr-icon>
<span class="text">Upload</span> <span class="text">Upload</span>
+217 -88
View File
@@ -41,9 +41,13 @@ import { LoggerService } from '../services/logger.service'
import { SasService } from '../services/sas.service' import { SasService } from '../services/sas.service'
import { UserService } from '../shared/user.service' import { UserService } from '../shared/user.service'
import { applyFormulaRules } from '../shared/dc-validator/utils/applyFormulaRules' import { applyFormulaRules } from '../shared/dc-validator/utils/applyFormulaRules'
import { findFormulaValueChanges } from '../shared/dc-validator/utils/findFormulaValueChanges' import { expandCellRanges } from './utils/expandCellRanges'
import { preventMenuItemAutoClose } from './utils/preventMenuItemAutoClose'
import { findOverwrittenCells } from '../shared/dc-validator/utils/findOverwrittenCells'
import { getFormulaCellsToPreserveOnCancel } from '../shared/dc-validator/utils/getFormulaCellsToPreserveOnCancel' import { getFormulaCellsToPreserveOnCancel } from '../shared/dc-validator/utils/getFormulaCellsToPreserveOnCancel'
import { getRevertableCols } from '../shared/dc-validator/utils/getRevertableCols'
import { getStableFormulaBaseCols } from '../shared/dc-validator/utils/getStableFormulaBaseCols' import { getStableFormulaBaseCols } from '../shared/dc-validator/utils/getStableFormulaBaseCols'
import { syncOverwrittenCellComment } from '../shared/dc-validator/utils/syncOverwrittenCellComment'
import { parseFormulaRule } from '../shared/dc-validator/utils/parseFormulaRule' import { parseFormulaRule } from '../shared/dc-validator/utils/parseFormulaRule'
import { DcValidator } from '../shared/dc-validator/dc-validator' import { DcValidator } from '../shared/dc-validator/dc-validator'
import { Col } from '../shared/dc-validator/models/col.model' import { Col } from '../shared/dc-validator/models/col.model'
@@ -204,49 +208,63 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
} }
} }
}, },
// Only ever shown for a cell markFormulaChangedCells attached a // Only ever shown when the selection contains at least one cell
// comment to (a HARDFORMULA/SOFTFORMULA cell whose formula // markOverwrittenCells (or the live afterChange sync) attached a
// overwrote a real, pre-existing value) - the comment's presence // comment to - i.e. a cell whose current value differs from what
// is a sufficient and exact signal, no need to separately // SAS actually sent for it, formula-caused or a direct edit. The
// re-check the column against the DQ rules here too. // comment's presence is a sufficient and exact signal, no need to
revert_formula_value: { // separately recompute "is this overwritten" here too. The
name: 'Revert value', // selection can be a single cell, a rectangular multi-cell range,
// a whole row/column (header click), or several disjoint ranges
// (ctrl-click) - expandCellRanges normalizes all of those into a
// flat list of individual cells.
revert_cells: {
name: 'Revert',
hidden(this: Handsontable.Core) { hidden(this: Handsontable.Core) {
if (this.getSettings().readOnly) return true if (this.getSettings().readOnly) return true
const fullCellRange: CellRange[] | undefined = const ranges: CellRange[] | undefined = this.getSelectedRange()
this.getSelectedRange() if (!ranges || ranges.length === 0) return true
if (!fullCellRange) return true
const { from, to } = fullCellRange[0]
if (from.row !== to.row || from.col !== to.col) return true
const commentsPlugin: any = this.getPlugin('comments') const commentsPlugin: any = this.getPlugin('comments')
const cells = expandCellRanges(
ranges,
this.countRows(),
this.countCols()
)
return !commentsPlugin.getCommentAtCell(from.row, from.col) return !cells.some(({ row, col }) =>
commentsPlugin.getCommentAtCell(row, col)
)
}, },
callback: (key: string, selection: any[]) => { callback: (key: string, selection: any[]) => {
const hot = this.hotInstance const hot = this.hotInstance
const { row, col } = selection[0].start
const prop = hot.colToProp(col) as string
const commentsPlugin: any = hot.getPlugin('comments') const commentsPlugin: any = hot.getPlugin('comments')
const comment: string | undefined = const cells = expandCellRanges(
commentsPlugin.getCommentAtCell(row, col) selection.map((sel) => ({ from: sel.start, to: sel.end })),
if (!comment) return hot.countRows(),
hot.countCols()
const rawValueText = comment.slice(
EditorComponent.ORIGINAL_VALUE_COMMENT_PREFIX.length
) )
const isNumericCol =
this.dcValidator?.getRule(prop)?.type === 'numeric'
hot.setDataAtRowProp( for (const { row, col } of cells) {
row, const comment: string | undefined =
prop, commentsPlugin.getCommentAtCell(row, col)
isNumericCol ? Number(rawValueText) : rawValueText if (!comment) continue
)
commentsPlugin.removeCommentAtCell(row, col) const prop = hot.colToProp(col) as string
const rawValueText = comment.slice(
EditorComponent.ORIGINAL_VALUE_COMMENT_PREFIX.length
)
const isNumericCol =
this.dcValidator?.getRule(prop)?.type === 'numeric'
hot.setDataAtRowProp(
row,
prop,
isNumericCol ? Number(rawValueText) : rawValueText
)
commentsPlugin.removeCommentAtCell(row, col)
}
} }
}, },
row_above: { row_above: {
@@ -405,9 +423,9 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
* with that cadence. * with that cadence.
*/ */
private static readonly VA_DEBOUNCE_MS = 800 private static readonly VA_DEBOUNCE_MS = 800
// Shared between markFormulaChangedCells (writes it) and the // Shared between markOverwrittenCells/syncOverwrittenCommentForCell
// revert_formula_value context menu item (parses it back out) - see // (writes it) and the revert_cells context menu item (parses it back
// findFormulaValueChanges. // out) - see findOverwrittenCells.
private static readonly ORIGINAL_VALUE_COMMENT_PREFIX = 'Original value: ' private static readonly ORIGINAL_VALUE_COMMENT_PREFIX = 'Original value: '
public tableTrue: boolean | undefined public tableTrue: boolean | undefined
public saveLoading = false public saveLoading = false
@@ -464,8 +482,8 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
prevDataSource!: any[] prevDataSource!: any[]
dataSourceUnchanged!: any[] dataSourceUnchanged!: any[]
// Raw, as-received-from-SAS snapshot, captured before applyFormulaRules // Raw, as-received-from-SAS snapshot, captured before applyFormulaRules
// overwrites HARDFORMULA/SOFTFORMULA columns - see findFormulaValueChanges // overwrites HARDFORMULA/SOFTFORMULA columns - see findOverwrittenCells
// and getFormulaBaseCols. Distinct from dataSourceUnchanged, which is a // and getRevertableColumnNames. Distinct from dataSourceUnchanged, which is a
// per-editing-session baseline that gets reset on every editTable() call; // per-editing-session baseline that gets reset on every editTable() call;
// this stays fixed for the table's whole lifetime, since "what did the // this stays fixed for the table's whole lifetime, since "what did the
// real dataset actually have before any formula got involved" doesn't // real dataset actually have before any formula got involved" doesn't
@@ -1109,6 +1127,17 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
hot.render() hot.render()
// Resync every revertable cell's "overwritten" comment against
// dataSourceRaw on every entry into edit mode. Needed for the Excel
// upload path in particular (previewTableEditConfirm -> editTable(true))
// - the preview's bulk hot.updateSettings({data: ...}) fires afterChange
// with source 'loadData', which the live sync hook deliberately ignores
// (see its own comment), so an uploaded value that differs from the
// original SAS data would otherwise never get flagged/commented. A
// plain Edit-button click re-syncs the same (already-correct) state,
// same as cancelEdit() does for the read-only return path.
this.syncOverwrittenComments()
for (const sortConfig of sortConfigs) { for (const sortConfig of sortConfigs) {
columnSorting.sort(sortConfig) columnSorting.sort(sortConfig)
} }
@@ -1162,7 +1191,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
if (this.dataSourceUnchanged) { if (this.dataSourceUnchanged) {
// dataSourceUnchanged deliberately holds the RAW pre-formula value for // dataSourceUnchanged deliberately holds the RAW pre-formula value for
// a HARDFORMULA/SOFTFORMULA column that still has its // a HARDFORMULA/SOFTFORMULA column that still has its
// markFormulaChangedCells comment (see editTable's overlay) - needed // markOverwrittenCells comment (see editTable's overlay) - needed
// so classifyRow flags the row as modified, but it isn't a session // so classifyRow flags the row as modified, but it isn't a session
// edit to discard: the formula recomputes on every load regardless. // edit to discard: the formula recomputes on every load regardless.
// Snapshot those cells' live computed value before the blind restore // Snapshot those cells' live computed value before the blind restore
@@ -1201,6 +1230,14 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
false false
) )
// A cell's overwritten comment can be stale after this restore - e.g.
// a direct edit (not a formula overwrite) just got discarded, and
// dataSource now matches dataSourceRaw again for it. formula cells'
// comments are already correct (preserved above), so this is mostly
// about the general case, but running the same full resync either way
// is simpler than trying to only check what might have changed.
this.syncOverwrittenComments()
this.modifedRowsIndexes = [] this.modifedRowsIndexes = []
hot.validateCells() hot.validateCells()
// this.editRecordListeners(); // this.editRecordListeners();
@@ -1497,8 +1534,11 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
* BASE_COL names of every HARDFORMULA/SOFTFORMULA rule whose result is * BASE_COL names of every HARDFORMULA/SOFTFORMULA rule whose result is
* expected to be *stable* for a given row (see getStableFormulaBaseCols * expected to be *stable* for a given row (see getStableFormulaBaseCols
* for why DC.USER_NAME/DC.ORIG_VALUE/DC.ROW_STATUS-based rules are * for why DC.USER_NAME/DC.ORIG_VALUE/DC.ROW_STATUS-based rules are
* excluded). Used by markFormulaChangedCells and the dataSourceUnchanged * excluded). Used only by overlayFormulaRawValuesOnUnchanged and
* overlay in editTable() - NOT the same filter seedFormulaValuesForRow * cancelEdit's preserve-on-cancel patch, which exist specifically to work
* around dataSourceUnchanged's formula-only raw overlay - see those
* methods' own doc comments for why that stays narrower than
* getRevertableColumnNames(). NOT the same filter seedFormulaValuesForRow
* uses above, which seeds every formula column regardless of this * uses above, which seeds every formula column regardless of this
* distinction. * distinction.
*/ */
@@ -1512,7 +1552,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
/** /**
* Overlays the true raw (pre-formula) value onto dataSourceUnchanged for * Overlays the true raw (pre-formula) value onto dataSourceUnchanged for
* every HARDFORMULA/SOFTFORMULA base col that already had real data (see * every HARDFORMULA/SOFTFORMULA base col that already had real data (see
* markFormulaChangedCells) - mutates in place. A HARDFORMULA/SOFTFORMULA * markOverwrittenCells) - mutates in place. A HARDFORMULA/SOFTFORMULA
* rule can silently overwrite a value that already existed in the real * rule can silently overwrite a value that already existed in the real
* dataset - that's a real change classifyRow's diff should pick up, not * dataset - that's a real change classifyRow's diff should pick up, not
* something that only becomes visible once an edit session starts. Shared * something that only becomes visible once an edit session starts. Shared
@@ -1538,59 +1578,129 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
} }
/** /**
* Marks every cell where a HARDFORMULA/SOFTFORMULA rule silently changed * Every column eligible to be checked/marked as "overwritten" - every
* a value that already existed in the real dataset (see * revertable column (see getRevertableCols), not just the narrower
* findFormulaValueChanges) with a read-only comment showing the original * formula-only set getFormulaBaseCols() returns. Used by
* value - otherwise there's no visual difference between "the formula * markOverwrittenCells and the live afterChange sync - NOT by
* just filled in a blank" and "the formula overwrote real data no one * overlayFormulaRawValuesOnUnchanged/cancelEdit's preserve-on-cancel
* asked to change". Must run after hot.updateSettings() has processed the * patch, which stay scoped to getFormulaBaseCols() specifically (see
* formulas: getDataAtRowProp only resolves the live computed value once * those methods' own doc comments for why).
* HyperFormula has actually evaluated the cell, not the raw formula */
* string dataSource holds right after applyFormulaRules. private getRevertableColumnNames(): string[] {
const dqRules = this.dcValidator?.getDqDetails()
if (!dqRules) return []
return getRevertableCols(dqRules, this.headerColumns)
}
/**
* Sets or clears a single cell's "overwritten" comment, matching its
* current value against dataSourceRaw (PK-matched, so this is safe to
* call after a row insert/delete/sort has moved things around). No-op if
* the row has no PK match in dataSourceRaw (a newly-inserted row) - see
* findOverwrittenCells for why that's never revertable.
*/
private syncOverwrittenCommentForCell(rowIndex: number, prop: string): void {
const hot = this.hotInstance
const dataRow = this.dataSource[rowIndex]
if (!dataRow || !this.dataSourceRaw) return
const rawRow = this.dataSourceRaw.find((candidate) =>
this.headerPks.every((pk) => candidate[pk] === dataRow[pk])
)
if (!rawRow) return
const colIndex = hot.propToCol(prop) as number
const commentsPlugin = hot.getPlugin('comments')
const currentValue = hot.getDataAtRowProp(rowIndex, prop)
const hasCommentAlready = !!commentsPlugin.getCommentAtCell(
rowIndex,
colIndex
)
const action = syncOverwrittenCellComment(
currentValue,
rawRow[prop],
hasCommentAlready
)
if (action === 'set') {
commentsPlugin.setCommentAtCell(
rowIndex,
colIndex,
`${EditorComponent.ORIGINAL_VALUE_COMMENT_PREFIX}${rawRow[prop]}`
)
} else if (action === 'remove') {
commentsPlugin.removeCommentAtCell(rowIndex, colIndex)
}
}
/**
* Full resync of every revertable cell's "overwritten" comment against
* the current data. Used both at initial load (nothing has a comment
* yet, so this is purely additive) and after Cancel (an edit may have
* just been discarded, so a previously-set comment can now be stale) -
* Handsontable's comments plugin has no way to enumerate its own
* comments, so the only way to find a stale one is to re-check every
* candidate cell.
*/
private syncOverwrittenComments(): void {
const revertableCols = this.getRevertableColumnNames()
if (revertableCols.length === 0 || !this.dataSourceRaw) return
this.dataSource.forEach((_row, rowIndex) => {
for (const col of revertableCols) {
this.syncOverwrittenCommentForCell(rowIndex, col)
}
})
}
/**
* Marks every cell whose current value differs from what SAS actually
* sent for it (see findOverwrittenCells) with a read-only comment
* showing the original value - a HARDFORMULA/SOFTFORMULA rule silently
* overwriting real pre-existing data is one way this happens, but so is
* any direct edit; both need the same "here's what it used to be, and a
* way back" treatment. Must run after hot.updateSettings() has processed
* the formulas: getDataAtRowProp only resolves a formula cell's live
* computed value once HyperFormula has actually evaluated it, not the
* raw formula string dataSource holds right after applyFormulaRules.
* *
* Also flips each affected row's EDIT_STATUS to 'M' - this is a real, * Also flips each affected row's EDIT_STATUS to 'M' - this is a real,
* permanent difference from the raw dataset (every future load recomputes * permanent difference from the raw dataset (every future load
* the same formula again), not a session edit afterChange would ever see, * recomputes the same formula again, and a direct edit made before this
* so nothing else would otherwise mark these rows modified. Doing this * method ever ran isn't something afterChange would have seen), so
* once here, before dataSourceUnchanged is ever cloned from dataSource, * nothing else would otherwise mark these rows modified. Doing this once
* means the 'M' baseline is already shared by both snapshots - no special * here, before dataSourceUnchanged is ever cloned from dataSource, means
* handling needed to keep it from being wiped out by a later cancelEdit(). * the 'M' baseline is already shared by both snapshots - no special
* handling needed to keep it from being wiped out by a later
* cancelEdit().
*/ */
private markFormulaChangedCells(): void { private markOverwrittenCells(): void {
this.syncOverwrittenComments()
const hot = this.hotInstance const hot = this.hotInstance
const formulaBaseCols = this.getFormulaBaseCols() const revertableCols = this.getRevertableColumnNames()
if (formulaBaseCols.length === 0 || !this.dataSourceRaw) return if (revertableCols.length === 0 || !this.dataSourceRaw) return
const computedRows = this.dataSource.map((_row, rowIndex) => const currentRows = this.dataSource.map((row, rowIndex) => ({
Object.fromEntries( ...row,
formulaBaseCols.map((baseCol) => [ ...Object.fromEntries(
baseCol, revertableCols.map((col) => [col, hot.getDataAtRowProp(rowIndex, col)])
hot.getDataAtRowProp(rowIndex, baseCol)
])
) )
) }))
const changes = findFormulaValueChanges( const changes = findOverwrittenCells(
computedRows, currentRows,
this.dataSourceRaw, this.dataSourceRaw,
formulaBaseCols revertableCols,
this.headerPks
) )
const changedRows = new Set(changes.map((change) => change.rowIndex))
const commentsPlugin = hot.getPlugin('comments')
const changedRows = new Set<number>()
for (const change of changes) {
commentsPlugin.setCommentAtCell(
change.rowIndex,
hot.propToCol(change.baseCol) as number,
`${EditorComponent.ORIGINAL_VALUE_COMMENT_PREFIX}${change.originalValue}`
)
changedRows.add(change.rowIndex)
}
// Not classifyRow/updateEditStatusForRow - dataSourceUnchanged doesn't // Not classifyRow/updateEditStatusForRow - dataSourceUnchanged doesn't
// exist yet this early (only editTable() sets it), and these rows are // exist yet this early (only editTable() sets it), and these rows are
// already known-modified from the comment loop above; no need to // already known-modified from the comment sync above; no need to
// reclassify. // reclassify.
// //
// Deferred: setDataAtRowProp needs the Formulas plugin's hidden-column // Deferred: setDataAtRowProp needs the Formulas plugin's hidden-column
@@ -3490,7 +3600,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
this.$dataFormats = response.data.$sasdata this.$dataFormats = response.data.$sasdata
// Raw, as-received snapshot - captured before applyFormulaRules below // Raw, as-received snapshot - captured before applyFormulaRules below
// overwrites HARDFORMULA/SOFTFORMULA columns, so markFormulaChangedCells // overwrites HARDFORMULA/SOFTFORMULA columns, so markOverwrittenCells
// can later tell "the formula filled in a blank" apart from "the // can later tell "the formula filled in a blank" apart from "the
// formula overwrote a value the real dataset already had". // formula overwrote a value the real dataset already had".
this.dataSourceRaw = this.helperService.deepClone(this.dataSource) this.dataSourceRaw = this.helperService.deepClone(this.dataSource)
@@ -3520,7 +3630,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
// Seeded here too (not just editTable()) so a HARDFORMULA/SOFTFORMULA // Seeded here too (not just editTable()) so a HARDFORMULA/SOFTFORMULA
// rule that silently overwrote real pre-existing data (see // rule that silently overwrote real pre-existing data (see
// markFormulaChangedCells) already shows the row as modified - both the // markOverwrittenCells) already shows the row as modified - both the
// '~' row header and EDIT_STATUS='M' - on the very first render, before // '~' row header and EDIT_STATUS='M' - on the very first render, before
// the user ever clicks Edit. editTable() rebuilds this fresh every time // the user ever clicks Edit. editTable() rebuilds this fresh every time
// it runs regardless, so setting it here doesn't affect that. // it runs regardless, so setting it here doesn't affect that.
@@ -3573,9 +3683,10 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
// collision-proof dotted name. // collision-proof dotted name.
dataDotNotation: false, dataDotNotation: false,
// readOnly here means comments can never be added/edited/removed // readOnly here means comments can never be added/edited/removed
// through the UI - only markFormulaChangedCells (via the plugin // through the UI - only markOverwrittenCells/
// API) ever sets one. The context menu below only offers our own // syncOverwrittenCommentForCell (via the plugin API) ever set one.
// "Revert value" item, never the plugin's own add/edit/remove ones. // The context menu below only offers our own "Revert" item, never
// the plugin's own add/edit/remove ones.
comments: { readOnly: true }, comments: { readOnly: true },
stretchH: 'all', stretchH: 'all',
readOnly: this.hotTable.readOnly, readOnly: this.hotTable.readOnly,
@@ -3650,6 +3761,12 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
}, },
info: { info: {
name: 'test info', name: 'test info',
// Purely informational (no callback) - without this, Handsontable
// still treats a click landing anywhere inside it as "the item
// was activated" (see preventMenuItemAutoClose's own comment),
// auto-closing the menu and blocking the native right-click
// "Copy" menu before the user can select any of the text.
isCommand: false,
renderer: ( renderer: (
hot: Handsontable.Core, hot: Handsontable.Core,
wrapper: HTMLElement, wrapper: HTMLElement,
@@ -3689,6 +3806,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
} }
elem.innerHTML = textInfo elem.innerHTML = textInfo
preventMenuItemAutoClose(elem)
return elem return elem
} }
@@ -3778,7 +3896,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
// resolves formulas' live computed values once HyperFormula has // resolves formulas' live computed values once HyperFormula has
// actually evaluated them against the data/formulas settings just // actually evaluated them against the data/formulas settings just
// applied. // applied.
this.markFormulaChangedCells() this.markOverwrittenCells()
this.hotTable.hidden = false this.hotTable.hidden = false
// Keep the context menu enabled in view mode too so Copy/Export remain // Keep the context menu enabled in view mode too so Copy/Export remain
@@ -3912,9 +4030,16 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
// (initial load, cancelSubmit, ...) where dataSource is already // (initial load, cancelSubmit, ...) where dataSource is already
// consistent, so it's skipped; 'editStatus' is this same hook's own // consistent, so it's skipped; 'editStatus' is this same hook's own
// writes (via updateEditStatusForRow), skipped to avoid recursion. // writes (via updateEditStatusForRow), skipped to avoid recursion.
//
// Also keeps each edited cell's "overwritten" comment in sync live -
// markOverwrittenCells only runs once, at initial load, so a direct
// edit made afterward (to a cell that wasn't already overwritten by a
// formula) needs its own comment set here; typing a value back to
// match the original just as readily needs that comment removed again.
hot.addHook('afterChange', (changes: any[], source: any) => { hot.addHook('afterChange', (changes: any[], source: any) => {
if (!changes || source === 'loadData' || source === 'editStatus') return if (!changes || source === 'loadData' || source === 'editStatus') return
const revertableCols = this.getRevertableColumnNames()
const changedRows = new Set<number>() const changedRows = new Set<number>()
for (const change of changes) { for (const change of changes) {
if (!change) continue if (!change) continue
@@ -3923,6 +4048,10 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
if (prop === EDIT_STATUS_COLUMN_NAME) continue if (prop === EDIT_STATUS_COLUMN_NAME) continue
changedRows.add(row) changedRows.add(row)
if (revertableCols.includes(prop)) {
this.syncOverwrittenCommentForCell(row, prop)
}
} }
for (const row of changedRows) this.updateEditStatusForRow(row) for (const row of changedRows) this.updateEditStatusForRow(row)
@@ -0,0 +1,101 @@
import { expandCellRanges } from './expandCellRanges'
describe('expandCellRanges', () => {
it('expands a single-cell range', () => {
expect(
expandCellRanges(
[{ from: { row: 1, col: 1 }, to: { row: 1, col: 1 } }],
3,
3
)
).toEqual([{ row: 1, col: 1 }])
})
it('expands a rectangular multi-cell range', () => {
expect(
expandCellRanges(
[{ from: { row: 0, col: 0 }, to: { row: 1, col: 1 } }],
3,
3
)
).toEqual([
{ row: 0, col: 0 },
{ row: 0, col: 1 },
{ row: 1, col: 0 },
{ row: 1, col: 1 }
])
})
it('normalizes a whole-row selection (from.col === -1) to every real column', () => {
expect(
expandCellRanges(
[{ from: { row: 1, col: -1 }, to: { row: 1, col: 2 } }],
3,
3
)
).toEqual([
{ row: 1, col: 0 },
{ row: 1, col: 1 },
{ row: 1, col: 2 }
])
})
it('normalizes a whole-column selection (from.row === -1) to every real row', () => {
expect(
expandCellRanges(
[{ from: { row: -1, col: 1 }, to: { row: 2, col: 1 } }],
3,
3
)
).toEqual([
{ row: 0, col: 1 },
{ row: 1, col: 1 },
{ row: 2, col: 1 }
])
})
it('expands every disjoint range when multiple are given (ctrl-click)', () => {
expect(
expandCellRanges(
[
{ from: { row: 0, col: 0 }, to: { row: 0, col: 0 } },
{ from: { row: 2, col: 2 }, to: { row: 2, col: 2 } }
],
3,
3
)
).toEqual([
{ row: 0, col: 0 },
{ row: 2, col: 2 }
])
})
it('treats a null row/col the same as -1 (Handsontable.CellRange types these as number | null)', () => {
expect(
expandCellRanges(
[{ from: { row: 1, col: null }, to: { row: 1, col: 2 } }],
3,
3
)
).toEqual([
{ row: 1, col: 0 },
{ row: 1, col: 1 },
{ row: 1, col: 2 }
])
})
it('handles from/to given in reverse order (drag selection upward/leftward)', () => {
expect(
expandCellRanges(
[{ from: { row: 2, col: 2 }, to: { row: 1, col: 1 } }],
3,
3
)
).toEqual([
{ row: 1, col: 1 },
{ row: 1, col: 2 },
{ row: 2, col: 1 },
{ row: 2, col: 2 }
])
})
})
@@ -0,0 +1,54 @@
export interface SimpleCellRange {
from: { row: number | null; col: number | null }
to: { row: number | null; col: number | null }
}
export interface SimpleCell {
row: number
col: number
}
/**
* Expands one or more Handsontable selection ranges (as returned by
* getSelectedRange()/the context menu callback's selection argument) into
* every individual (row, col) cell they cover. A whole-row selection (row
* header click) reports col: -1 (Handsontable.CellRange itself types this
* as number | null, so null is treated the same way here) on whichever end
* is the "start" of the range; a whole-column selection reports row: -1
* the same way - normalized here to the real 0..totalRows-1/0..totalCols-1
* bounds, since neither -1 nor null is a usable index for anything
* downstream (comments lookup, setDataAtRowProp, ...). Multiple ranges
* (ctrl-click) are all expanded, not just the first.
*/
export const expandCellRanges = (
ranges: SimpleCellRange[],
totalRows: number,
totalCols: number
): SimpleCell[] => {
const cells: SimpleCell[] = []
const normalizeRow = (row: number | null) =>
row === -1 || row === null ? undefined : row
const normalizeCol = (col: number | null) =>
col === -1 || col === null ? undefined : col
for (const range of ranges) {
const rowA = normalizeRow(range.from.row) ?? 0
const rowB = normalizeRow(range.to.row) ?? totalRows - 1
const colA = normalizeCol(range.from.col) ?? 0
const colB = normalizeCol(range.to.col) ?? totalCols - 1
const startRow = Math.min(rowA, rowB)
const endRow = Math.max(rowA, rowB)
const startCol = Math.min(colA, colB)
const endCol = Math.max(colA, colB)
for (let row = startRow; row <= endRow; row++) {
for (let col = startCol; col <= endCol; col++) {
cells.push({ row, col })
}
}
}
return cells
}
@@ -0,0 +1,53 @@
import { preventMenuItemAutoClose } from './preventMenuItemAutoClose'
describe('preventMenuItemAutoClose', () => {
let parent: HTMLElement
let child: HTMLElement
beforeEach(() => {
parent = document.createElement('div')
child = document.createElement('span')
parent.appendChild(child)
document.body.appendChild(parent)
})
afterEach(() => {
document.body.removeChild(parent)
})
const bubblingEvents = ['mousedown', 'mouseup', 'contextmenu', 'selectstart']
bubblingEvents.forEach((eventName) => {
it(`stops a ${eventName} dispatched on the element from bubbling to its parent`, () => {
let bubbledToParent = false
parent.addEventListener(eventName, () => (bubbledToParent = true))
preventMenuItemAutoClose(child)
child.dispatchEvent(new Event(eventName, { bubbles: true }))
expect(bubbledToParent).toBe(false)
})
it(`still lets a ${eventName} dispatched directly on the parent reach the parent (propagation isn't globally broken)`, () => {
let bubbledToParent = false
parent.addEventListener(eventName, () => (bubbledToParent = true))
preventMenuItemAutoClose(child)
parent.dispatchEvent(new Event(eventName, { bubbles: true }))
expect(bubbledToParent).toBe(true)
})
})
it('does not call preventDefault on contextmenu, so the browser can still show its own menu', () => {
preventMenuItemAutoClose(child)
const event = new Event('contextmenu', {
bubbles: true,
cancelable: true
})
child.dispatchEvent(event)
expect(event.defaultPrevented).toBe(false)
})
})
@@ -0,0 +1,23 @@
// Handsontable's Menu widget (used by both dropdownMenu and contextMenu)
// treats a click landing anywhere inside a non-passive item as "the item was
// activated": mouseup auto-closes the menu, and contextmenu is
// unconditionally preventDefault()'d, so the browser's own right-click menu
// never appears - regardless of whether the item actually has a callback.
// A custom-rendered, read-only item (plain informational text, nothing to
// click) still gets this treatment, making its content impossible to select
// or copy. Handsontable's own listeners are bubble-phase, attached on
// ancestors of the rendered item element, so stopping propagation at the
// item itself is enough to reach them before they run - no capture-phase
// handling needed.
const EVENTS_TO_ISOLATE = [
'mousedown',
'mouseup',
'contextmenu',
'selectstart'
] as const
export const preventMenuItemAutoClose = (elem: HTMLElement): void => {
for (const eventName of EVENTS_TO_ISOLATE) {
elem.addEventListener(eventName, (event) => event.stopPropagation())
}
}
@@ -1,84 +0,0 @@
import { findFormulaValueChanges } from './findFormulaValueChanges'
describe('findFormulaValueChanges', () => {
it('reports a change when the computed value differs from a meaningful raw value', () => {
const computedRows = [{ FORMULA_HARD_COL: 20, FORMULA_SOFT_COL: 12 }]
const rawRows = [{ FORMULA_HARD_COL: 1111, FORMULA_SOFT_COL: 2222 }]
expect(
findFormulaValueChanges(computedRows, rawRows, [
'FORMULA_HARD_COL',
'FORMULA_SOFT_COL'
])
).toEqual([
{ rowIndex: 0, baseCol: 'FORMULA_HARD_COL', originalValue: 1111 },
{ rowIndex: 0, baseCol: 'FORMULA_SOFT_COL', originalValue: 2222 }
])
})
it('reports nothing when the computed value matches the raw value', () => {
const computedRows = [{ FORMULA_HARD_COL: 20 }]
const rawRows = [{ FORMULA_HARD_COL: 20 }]
expect(
findFormulaValueChanges(computedRows, rawRows, ['FORMULA_HARD_COL'])
).toEqual([])
})
it('compares loosely (string vs number) so a "20" raw value matching a 20 computed value is not reported', () => {
const computedRows = [{ FORMULA_HARD_COL: 20 }]
const rawRows = [{ FORMULA_HARD_COL: '20' }]
expect(
findFormulaValueChanges(computedRows, rawRows, ['FORMULA_HARD_COL'])
).toEqual([])
})
it('ignores a column with no meaningful raw value (blank/null/undefined) - nothing to have changed from', () => {
const computedRows = [
{ FORMULA_HARD_COL: 20 },
{ FORMULA_HARD_COL: 20 },
{ FORMULA_HARD_COL: 20 }
]
const rawRows = [
{ FORMULA_HARD_COL: '' },
{ FORMULA_HARD_COL: null },
{ FORMULA_HARD_COL: undefined }
]
expect(
findFormulaValueChanges(computedRows, rawRows, ['FORMULA_HARD_COL'])
).toEqual([])
})
it('only reports the rows/columns that actually changed, across multiple rows', () => {
const computedRows = [
{ FORMULA_HARD_COL: 20, FORMULA_SOFT_COL: 12 },
{ FORMULA_HARD_COL: 30, FORMULA_SOFT_COL: 13 }
]
const rawRows = [
{ FORMULA_HARD_COL: 20, FORMULA_SOFT_COL: 2222 },
{ FORMULA_HARD_COL: 30, FORMULA_SOFT_COL: 13 }
]
expect(
findFormulaValueChanges(computedRows, rawRows, [
'FORMULA_HARD_COL',
'FORMULA_SOFT_COL'
])
).toEqual([
{ rowIndex: 0, baseCol: 'FORMULA_SOFT_COL', originalValue: 2222 }
])
})
it('is safe when a raw row is missing (e.g. array length mismatch) - skips it rather than throwing', () => {
const computedRows = [{ FORMULA_HARD_COL: 20 }, { FORMULA_HARD_COL: 30 }]
const rawRows = [{ FORMULA_HARD_COL: 1111 }]
expect(
findFormulaValueChanges(computedRows, rawRows, ['FORMULA_HARD_COL'])
).toEqual([
{ rowIndex: 0, baseCol: 'FORMULA_HARD_COL', originalValue: 1111 }
])
})
})
@@ -1,40 +0,0 @@
export interface FormulaValueChange {
rowIndex: number
baseCol: string
originalValue: unknown
}
/**
* Finds every (row, HARDFORMULA/SOFTFORMULA column) pair where the value
* computed by the formula differs from the real, raw value the dataset
* already had for that cell - i.e. adding the formula rule silently changed
* a value that pre-existed in the actual data, not just filled in a blank.
* Compared loosely (via string coercion) since the raw value arrives as
* whatever type SAS sent while the computed value is HyperFormula's own
* (often numeric) result for the same underlying number.
*/
export const findFormulaValueChanges = (
computedRows: Record<string, unknown>[],
rawRows: Record<string, unknown>[],
formulaBaseCols: string[]
): FormulaValueChange[] => {
const changes: FormulaValueChange[] = []
computedRows.forEach((row, rowIndex) => {
const rawRow = rawRows[rowIndex]
if (!rawRow) return
for (const baseCol of formulaBaseCols) {
const rawValue = rawRow[baseCol]
if (rawValue === undefined || rawValue === null || rawValue === '')
continue
const computedValue = row[baseCol]
if (String(rawValue) === String(computedValue)) continue
changes.push({ rowIndex, baseCol, originalValue: rawValue })
}
})
return changes
}
@@ -0,0 +1,122 @@
import { findOverwrittenCells } from './findOverwrittenCells'
describe('findOverwrittenCells', () => {
it('reports a change when the current value differs from a meaningful raw value', () => {
const currentRows = [
{ PRIMARY_KEY_FIELD: 1, FORMULA_HARD_COL: 20, FORMULA_SOFT_COL: 12 }
]
const rawRows = [
{ PRIMARY_KEY_FIELD: 1, FORMULA_HARD_COL: 1111, FORMULA_SOFT_COL: 2222 }
]
expect(
findOverwrittenCells(
currentRows,
rawRows,
['FORMULA_HARD_COL', 'FORMULA_SOFT_COL'],
['PRIMARY_KEY_FIELD']
)
).toEqual([
{ rowIndex: 0, col: 'FORMULA_HARD_COL', originalValue: 1111 },
{ rowIndex: 0, col: 'FORMULA_SOFT_COL', originalValue: 2222 }
])
})
it('reports nothing when the current value matches the raw value', () => {
const currentRows = [{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'unchanged' }]
const rawRows = [{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'unchanged' }]
expect(
findOverwrittenCells(
currentRows,
rawRows,
['SOME_CHAR'],
['PRIMARY_KEY_FIELD']
)
).toEqual([])
})
it('compares loosely (string vs number) so a "20" raw value matching a 20 current value is not reported', () => {
const currentRows = [{ PRIMARY_KEY_FIELD: 1, SOME_NUM: 20 }]
const rawRows = [{ PRIMARY_KEY_FIELD: 1, SOME_NUM: '20' }]
expect(
findOverwrittenCells(
currentRows,
rawRows,
['SOME_NUM'],
['PRIMARY_KEY_FIELD']
)
).toEqual([])
})
it('ignores a column with no meaningful raw value (blank/null/undefined) - nothing to have changed from', () => {
const currentRows = [
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'a' },
{ PRIMARY_KEY_FIELD: 2, SOME_CHAR: 'b' },
{ PRIMARY_KEY_FIELD: 3, SOME_CHAR: 'c' }
]
const rawRows = [
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: '' },
{ PRIMARY_KEY_FIELD: 2, SOME_CHAR: null },
{ PRIMARY_KEY_FIELD: 3, SOME_CHAR: undefined }
]
expect(
findOverwrittenCells(
currentRows,
rawRows,
['SOME_CHAR'],
['PRIMARY_KEY_FIELD']
)
).toEqual([])
})
it('only reports the rows/columns that actually changed, across multiple rows', () => {
const currentRows = [
{ PRIMARY_KEY_FIELD: 1, A: 20, B: 12 },
{ PRIMARY_KEY_FIELD: 2, A: 30, B: 13 }
]
const rawRows = [
{ PRIMARY_KEY_FIELD: 1, A: 20, B: 2222 },
{ PRIMARY_KEY_FIELD: 2, A: 30, B: 13 }
]
expect(
findOverwrittenCells(
currentRows,
rawRows,
['A', 'B'],
['PRIMARY_KEY_FIELD']
)
).toEqual([{ rowIndex: 0, col: 'B', originalValue: 2222 }])
})
it('matches rows by primary key, not array position', () => {
const currentRows = [
{ PRIMARY_KEY_FIELD: 2, A: 999 },
{ PRIMARY_KEY_FIELD: 1, A: 20 }
]
// rawRows deliberately in a different order than currentRows
const rawRows = [
{ PRIMARY_KEY_FIELD: 1, A: 20 },
{ PRIMARY_KEY_FIELD: 2, A: 30 }
]
expect(
findOverwrittenCells(currentRows, rawRows, ['A'], ['PRIMARY_KEY_FIELD'])
).toEqual([{ rowIndex: 0, col: 'A', originalValue: 30 }])
})
it('skips a row with no primary-key match in rawRows (e.g. a newly-inserted row)', () => {
const currentRows = [
{ PRIMARY_KEY_FIELD: 1, A: 20 },
{ PRIMARY_KEY_FIELD: undefined, A: 999 }
]
const rawRows = [{ PRIMARY_KEY_FIELD: 1, A: 1111 }]
expect(
findOverwrittenCells(currentRows, rawRows, ['A'], ['PRIMARY_KEY_FIELD'])
).toEqual([{ rowIndex: 0, col: 'A', originalValue: 1111 }])
})
})
@@ -0,0 +1,50 @@
export interface OverwrittenCell {
rowIndex: number
col: string
originalValue: unknown
}
/**
* Finds every (row, column) pair where the current value differs from the
* real, raw value SAS actually sent for it - i.e. something (a direct edit,
* paste, a formula, ...) silently changed a value that pre-existed in the
* actual data, not just filled in a blank. Rows are matched by primary key,
* not array position, so detection stays correct across row insert/delete/
* sort. A row with no PK match in rawRows (a newly-inserted row) is skipped
* entirely, since there's no original value to have overwritten. Compared
* loosely (via string coercion) since the raw value arrives as whatever
* type SAS sent while the current value may be a different but equal
* representation (e.g. HyperFormula's own numeric result).
*/
export const findOverwrittenCells = (
currentRows: Record<string, unknown>[],
rawRows: Record<string, unknown>[],
revertableCols: string[],
headerPks: string[]
): OverwrittenCell[] => {
const changes: OverwrittenCell[] = []
currentRows.forEach((row, rowIndex) => {
const rawRow = rawRows.find((candidate) =>
headerPks.every((pk) => candidate[pk] === row[pk])
)
if (!rawRow) return
for (const col of revertableCols) {
const rawValue = rawRow[col]
if (rawValue === undefined || rawValue === null || rawValue === '') {
continue
}
const currentValue = row[col]
if (String(rawValue) === String(currentValue)) continue
changes.push({ rowIndex, col, originalValue: rawValue })
}
})
return changes
}
@@ -0,0 +1,82 @@
import { DQRule } from '../models/dq-rules.model'
import { getRevertableCols } from './getRevertableCols'
const rule = (overrides: Partial<DQRule>): DQRule => ({
BASE_COL: 'SOME_COL',
RULE_TYPE: 'SOFTFORMULA',
RULE_VALUE: '=A_COL + B_COL',
X: 0,
...overrides
})
describe('getRevertableCols', () => {
it('excludes the delete-flag column', () => {
expect(
getRevertableCols(
[],
['_____DELETE__THIS__RECORD_____', 'PRIMARY_KEY_FIELD']
)
).toEqual(['PRIMARY_KEY_FIELD'])
})
it('excludes the delete-flag column even after the editor renames it to its display label', () => {
// editor.component.ts renames headerColumns' delete-flag entry to
// 'Delete?' in place (for colHeaders display) before this runs, while
// the actual Handsontable column data prop stays the raw name - so
// both forms must be excluded, or propToCol('Delete?') returns -1.
expect(getRevertableCols([], ['Delete?', 'PRIMARY_KEY_FIELD'])).toEqual([
'PRIMARY_KEY_FIELD'
])
})
it('excludes the hidden dc.row_status column', () => {
expect(
getRevertableCols([], ['PRIMARY_KEY_FIELD', 'dc.row_status'])
).toEqual(['PRIMARY_KEY_FIELD'])
})
it('excludes a DC.*-referencing formula column', () => {
expect(
getRevertableCols(
[
rule({
BASE_COL: 'CHANGE_SUMMARY_COL',
RULE_VALUE: '=DC.ROW_STATUS'
})
],
['PRIMARY_KEY_FIELD', 'CHANGE_SUMMARY_COL']
)
).toEqual(['PRIMARY_KEY_FIELD'])
})
it('includes a plain column-arithmetic formula column', () => {
expect(
getRevertableCols(
[
rule({
BASE_COL: 'FORMULA_SOFT_COL',
RULE_TYPE: 'SOFTFORMULA',
RULE_VALUE: '=A_COL + B_COL'
})
],
['PRIMARY_KEY_FIELD', 'FORMULA_SOFT_COL']
)
).toEqual(['PRIMARY_KEY_FIELD', 'FORMULA_SOFT_COL'])
})
it('includes an ordinary non-formula column', () => {
expect(getRevertableCols([], ['PRIMARY_KEY_FIELD', 'SOME_CHAR'])).toEqual([
'PRIMARY_KEY_FIELD',
'SOME_CHAR'
])
})
it('includes a column governed only by an unrelated rule type (e.g. HARDSELECT)', () => {
expect(
getRevertableCols(
[rule({ BASE_COL: 'SOME_HARDSELECT', RULE_TYPE: 'HARDSELECT' })],
['PRIMARY_KEY_FIELD', 'SOME_HARDSELECT']
)
).toEqual(['PRIMARY_KEY_FIELD', 'SOME_HARDSELECT'])
})
})
@@ -0,0 +1,47 @@
import { DQRule } from '../models/dq-rules.model'
import { DELETE_RECORD_COLUMN_RULE } from './deleteRecordColumnRule'
import { EDIT_STATUS_COLUMN_NAME } from './editStatusColumnRule'
import { getStableFormulaBaseCols } from './getStableFormulaBaseCols'
/**
* Display label editor.component.ts renames the delete-flag column's
* headerColumns entry to (see initSetup) - the actual Handsontable column
* data prop stays DELETE_RECORD_COLUMN_RULE.data, so by the time
* headerColumns reaches this function the raw name is already gone from
* it and only this label is left to match against.
*/
const DELETE_RECORD_COLUMN_LABEL = 'Delete?'
/**
* Every column eligible to be checked/marked as "overwritten" (see
* findOverwrittenCells) - all of headerColumns except the delete-flag
* column, the hidden EDIT_STATUS column, and any HARDFORMULA/SOFTFORMULA
* column excluded by getStableFormulaBaseCols (DC.USER_NAME/DC.ORIG_VALUE/
* DC.ROW_STATUS-referencing formulas are inherently session-dependent, so
* comparing their live result against the raw SAS value is never a
* meaningful "was this overwritten" signal).
*/
export const getRevertableCols = (
dqRules: DQRule[],
headerColumns: string[]
): string[] => {
const stableFormulaBaseCols = new Set(getStableFormulaBaseCols(dqRules))
const unstableFormulaBaseCols = new Set(
dqRules
.filter(
(rule) =>
(rule.RULE_TYPE === 'HARDFORMULA' ||
rule.RULE_TYPE === 'SOFTFORMULA') &&
!stableFormulaBaseCols.has(rule.BASE_COL)
)
.map((rule) => rule.BASE_COL)
)
return headerColumns.filter(
(col) =>
col !== DELETE_RECORD_COLUMN_RULE.data &&
col !== DELETE_RECORD_COLUMN_LABEL &&
col !== EDIT_STATUS_COLUMN_NAME &&
!unstableFormulaBaseCols.has(col)
)
}
@@ -0,0 +1,29 @@
import { syncOverwrittenCellComment } from './syncOverwrittenCellComment'
describe('syncOverwrittenCellComment', () => {
it('sets a comment when the value now differs from raw and none exists yet', () => {
expect(syncOverwrittenCellComment('999', 'orig', false)).toBe('set')
})
it('does nothing when the value still differs and a comment is already there', () => {
expect(syncOverwrittenCellComment('999', 'orig', true)).toBe('none')
})
it('removes the comment when the value has been typed back to match the raw value', () => {
expect(syncOverwrittenCellComment('orig', 'orig', true)).toBe('remove')
})
it('does nothing when the value matches raw and there is no comment', () => {
expect(syncOverwrittenCellComment('orig', 'orig', false)).toBe('none')
})
it('treats a blank/null/undefined raw value as never meaningful, even with a stale comment present', () => {
expect(syncOverwrittenCellComment('999', '', true)).toBe('remove')
expect(syncOverwrittenCellComment('999', null, true)).toBe('remove')
expect(syncOverwrittenCellComment('999', undefined, true)).toBe('remove')
})
it('compares loosely (string vs number) so equivalent values are not treated as overwritten', () => {
expect(syncOverwrittenCellComment(20, '20', false)).toBe('none')
})
})
@@ -0,0 +1,26 @@
export type OverwrittenCommentAction = 'set' | 'remove' | 'none'
/**
* Decides what a single cell's "overwritten" comment should do in response
* to a live edit (afterChange) - detection must run on every edit, since an
* edit can just as easily make an already-overwritten cell match its raw
* value again (typed back by hand) as it can make an untouched cell diverge
* from it. Uses the same "loosely equal, blank raw is never meaningful"
* rule as findOverwrittenCells, since a single afterChange call operates on
* one cell at a time, not the whole-grid batch that function expects.
*/
export const syncOverwrittenCellComment = (
currentValue: unknown,
rawValue: unknown,
hasCommentAlready: boolean
): OverwrittenCommentAction => {
const hasMeaningfulRawValue =
rawValue !== undefined && rawValue !== null && rawValue !== ''
const isOverwritten =
hasMeaningfulRawValue && String(rawValue) !== String(currentValue)
if (isOverwritten) return hasCommentAlready ? 'none' : 'set'
return hasCommentAlready ? 'remove' : 'none'
}
+36 -11
View File
@@ -1622,6 +1622,17 @@ let webouts = {
DESC: "SOFTFORMULA: combines DC.ROW_STATUS/DC.USER_NAME/DC.ORIG_VALUE - 'unedited' while unchanged, else '<user> changed from <original value>'", DESC: "SOFTFORMULA: combines DC.ROW_STATUS/DC.USER_NAME/DC.ORIG_VALUE - 'unedited' while unchanged, else '<user> changed from <original value>'",
LONGDESC: "", LONGDESC: "",
COLTYPE: "{\"data\":\"CHANGE_SUMMARY_COL\"}" COLTYPE: "{\"data\":\"CHANGE_SUMMARY_COL\"}"
},
{
NAME: "PLAIN_TEXT_COL",
LABEL: "PLAIN_TEXT_COL",
FMTNAME: "",
DDTYPE: "C",
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "No DQ rule at all (not a formula column) - a plain character column for manually testing that revert/overwritten-comment also works when nothing but a direct edit (typing, paste, autofill) ever touches it. Its raw value can't be pre-seeded as already overwritten, unlike FORMULA_HARD_COL/FORMULA_SOFT_COL - see dataSourceRaw's own doc comment in editor.component.ts for why only formula columns can do that.",
LONGDESC: "",
COLTYPE: "{\"data\":\"PLAIN_TEXT_COL\"}"
} }
], ],
dqdata: [], dqdata: [],
@@ -1639,7 +1650,7 @@ let webouts = {
{ ODS_TABLE: "ATTRIBUTES", NAME: "Member Type", VALUE: "DATA" }, { ODS_TABLE: "ATTRIBUTES", NAME: "Member Type", VALUE: "DATA" },
{ ODS_TABLE: "ATTRIBUTES", NAME: "Engine", VALUE: "V9" }, { ODS_TABLE: "ATTRIBUTES", NAME: "Engine", VALUE: "V9" },
{ ODS_TABLE: "ATTRIBUTES", NAME: "Observations", VALUE: "10" }, { ODS_TABLE: "ATTRIBUTES", NAME: "Observations", VALUE: "10" },
{ ODS_TABLE: "ATTRIBUTES", NAME: "Variables", VALUE: "9" } { ODS_TABLE: "ATTRIBUTES", NAME: "Variables", VALUE: "10" }
], ],
maxvarlengths: [ maxvarlengths: [
{ NAME: "_____DELETE__THIS__RECORD_____", MAXLEN: 3 }, { NAME: "_____DELETE__THIS__RECORD_____", MAXLEN: 3 },
@@ -1651,7 +1662,8 @@ let webouts = {
{ NAME: "row_status_col", MAXLEN: 128 }, { NAME: "row_status_col", MAXLEN: 128 },
{ NAME: "user_name_col", MAXLEN: 128 }, { NAME: "user_name_col", MAXLEN: 128 },
{ NAME: "orig_value_col", MAXLEN: 128 }, { NAME: "orig_value_col", MAXLEN: 128 },
{ NAME: "change_summary_col", MAXLEN: 128 } { NAME: "change_summary_col", MAXLEN: 128 },
{ NAME: "plain_text_col", MAXLEN: 128 }
], ],
query: [], query: [],
// 10 rows, well under the editor_rows_allowed=15 cap, with both // 10 rows, well under the editor_rows_allowed=15 cap, with both
@@ -1659,21 +1671,33 @@ let webouts = {
// and CHANGE_SUMMARY_COL are seeded with a distinctive raw value // and CHANGE_SUMMARY_COL are seeded with a distinctive raw value
// (not blank, unlike the other formula columns) so DC.ORIG_VALUE - // (not blank, unlike the other formula columns) so DC.ORIG_VALUE -
// which always echoes THIS SAME column's own pre-edit value, never // which always echoes THIS SAME column's own pre-edit value, never
// another column's - has something meaningful to echo back. Row 10 // another column's - has something meaningful to echo back.
// (i===9) additionally seeds FORMULA_HARD_COL/FORMULA_SOFT_COL with //
// real pre-existing values (1111/2222) that the HARDFORMULA/ // Rows 7-10 (i===6..9) additionally seed FORMULA_HARD_COL/
// SOFTFORMULA rules above overwrite (100/20) - see Cypress test 28. // FORMULA_SOFT_COL with real pre-existing values that the
// HARDFORMULA/SOFTFORMULA rules above overwrite on load - a
// deliberately staggered pattern (row 7: hard-only, row 8:
// soft-only, rows 9-10: both) so a manual/Cypress tester can
// right-click a whole ROW (some already-overwritten cells mixed
// with untouched ones - e.g. row 7), a whole COLUMN (overwritten
// cells scattered across only some of its rows), or a multi-row/
// multi-column range and see "Revert" appear/disappear correctly
// depending on whether the selection actually contains an
// overwritten cell. Rows 1-6 are left untouched so a selection
// confined to them proves the negative case (no "Revert" offered).
// See Cypress test 28 (row 10) and 32-35 (general revert coverage).
sasdata: Array.from({ length: 10 }, (_, i) => ({ sasdata: Array.from({ length: 10 }, (_, i) => ({
_____DELETE__THIS__RECORD_____: "No", _____DELETE__THIS__RECORD_____: "No",
PRIMARY_KEY_FIELD: i + 1, PRIMARY_KEY_FIELD: i + 1,
A_COL: i + 1, A_COL: i + 1,
B_COL: 10, B_COL: 10,
FORMULA_HARD_COL: i === 9 ? "1111" : "", FORMULA_HARD_COL: i === 6 ? "7771" : i === 8 ? "9991" : i === 9 ? "1111" : "",
FORMULA_SOFT_COL: i === 9 ? "2222" : "", FORMULA_SOFT_COL: i === 7 ? "8882" : i === 8 ? "9992" : i === 9 ? "2222" : "",
ROW_STATUS_COL: "", ROW_STATUS_COL: "",
USER_NAME_COL: "", USER_NAME_COL: "",
ORIG_VALUE_COL: `orig-${i + 1}`, ORIG_VALUE_COL: `orig-${i + 1}`,
CHANGE_SUMMARY_COL: `orig-${i + 1}` CHANGE_SUMMARY_COL: `orig-${i + 1}`,
PLAIN_TEXT_COL: `note-${i + 1}`
})), })),
$sasdata: { $sasdata: {
vars: { vars: {
@@ -1686,12 +1710,13 @@ let webouts = {
ROW_STATUS_COL: { format: "$128.", label: "ROW_STATUS_COL", length: "128", type: "char" }, ROW_STATUS_COL: { format: "$128.", label: "ROW_STATUS_COL", length: "128", type: "char" },
USER_NAME_COL: { format: "$128.", label: "USER_NAME_COL", length: "128", type: "char" }, USER_NAME_COL: { format: "$128.", label: "USER_NAME_COL", length: "128", type: "char" },
ORIG_VALUE_COL: { format: "$128.", label: "ORIG_VALUE_COL", length: "128", type: "char" }, ORIG_VALUE_COL: { format: "$128.", label: "ORIG_VALUE_COL", length: "128", type: "char" },
CHANGE_SUMMARY_COL: { format: "$128.", label: "CHANGE_SUMMARY_COL", length: "128", type: "char" } CHANGE_SUMMARY_COL: { format: "$128.", label: "CHANGE_SUMMARY_COL", length: "128", type: "char" },
PLAIN_TEXT_COL: { format: "$128.", label: "PLAIN_TEXT_COL", length: "128", type: "char" }
} }
}, },
sasparams: [ sasparams: [
{ {
COLHEADERS: "_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,A_COL,B_COL,FORMULA_HARD_COL,FORMULA_SOFT_COL,ROW_STATUS_COL,USER_NAME_COL,ORIG_VALUE_COL,CHANGE_SUMMARY_COL", COLHEADERS: "_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,A_COL,B_COL,FORMULA_HARD_COL,FORMULA_SOFT_COL,ROW_STATUS_COL,USER_NAME_COL,ORIG_VALUE_COL,CHANGE_SUMMARY_COL,PLAIN_TEXT_COL",
FILTER_TEXT: "", FILTER_TEXT: "",
PKCNT: 1, PKCNT: 1,
PK: "PRIMARY_KEY_FIELD", PK: "PRIMARY_KEY_FIELD",