Files
dc/client/src/app/editor/editor.component.ts
T
dc 3a6a216b9d
Build / Build-and-ng-test (pull_request) Successful in 5m28s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m29s
Build / Build-and-test-development (pull_request) Successful in 30m40s
fix(filters): source the picker variables from public/getcols when the table is not returned
A limit aborting the table request left the filter picker with no variables,
so the "please filter and try again" advice could not be acted on: the
picker's list came from the columns that arrived with the table, and there
was no table.

The list now falls back to public/getcols, which reads the column metadata
without the rows. The normal path is unchanged - the service is only called
when the caller has no columns to hand - so nothing extra is requested for a
table that loaded.

- SasStoreService.getCols() calls public/getcols and returns cols.
- SasStoreService.setQueryVariablesFromTable() takes the columns the caller
  already has and reaches for the service only when they are empty.
- editor openQb() and viewboxes openFilter() use it (the viewer's filter is
  reachable after a VIEW abort, the editor's after an EDIT abort).
- public/getcols.sas keeps ddtype (already computed, dropped by the keep) and
  excludes the temporal columns exactly as the editor does, so the fallback
  list matches the table's own.
- New mock public/getcols.js mirrors the service.

Verified: the mock's list matches editors/getdata for every fixture except the
aborting one (MPE_X_SEARCH: getdata 0 cols, getcols 9), and a new Cypress case
in row-cell-limits.cy.ts aborts a too-big EDIT, opens the filter and asserts
all nine columns are offered. 89 Cypress tests green across the six affected
specs.
2026-10-04 09:30:33 +00:00

5078 lines
177 KiB
TypeScript

import {
AfterViewChecked,
AfterViewInit,
ChangeDetectorRef,
Component,
ElementRef,
OnDestroy,
OnInit,
QueryList,
ViewChild,
ViewChildren,
ViewEncapsulation
} from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import Handsontable, { CellRange } from 'handsontable'
import { Subject, Subscription } from 'rxjs'
import { sanitiseForSas } from '../shared/utils/sanitise'
import { SasStoreService } from '../services/sas-store.service'
type AOA = any[][]
import { HotTableComponent } from '@handsontable/angular-wrapper'
import { UploadFile } from '@sasjs/adapter'
import { isSpecialMissing } from '@sasjs/utils/input/validators'
import { CellValidationSource } from '../models/CellValidationSource'
import { FileUploader } from '../models/FileUploader.class'
import { FilterGroup, FilterQuery } from '../models/FilterQuery'
import { HotTableInterface } from '../models/HotTable.interface'
import { buildExportMenuItem } from '../shared/hot-export/hot-export.util'
import {
$DataFormats,
DSMeta,
EditorsGetDataServiceResponse,
Version
} from '../models/sas/editors-getdata.model'
import { DataFormat } from '../models/sas/common/DateFormat'
import { Approver, ExcelRule, QueryClause } from '../models/TableData'
import { QueryComponent } from '../query/query.component'
import { EventService } from '../services/event.service'
import { HelperService } from '../services/helper.service'
import { LoggerService } from '../services/logger.service'
import { SasService } from '../services/sas.service'
import { UserService } from '../shared/user.service'
import { applyFormulaRules } from '../shared/dc-validator/utils/applyFormulaRules'
import { 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 { getRevertableCols } from '../shared/dc-validator/utils/getRevertableCols'
import { resolveRevertedCellValue } from '../shared/dc-validator/utils/resolveRevertedCellValue'
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 { substituteColumnReferences } from '../shared/dc-validator/utils/substituteColumnReferences'
import { DcValidator } from '../shared/dc-validator/dc-validator'
import { Col } from '../shared/dc-validator/models/col.model'
import { DcValidation } from '../shared/dc-validator/models/dc-validation.model'
import { DQRule } from '../shared/dc-validator/models/dq-rules.model'
import { getHotDataSchema } from '../shared/dc-validator/utils/getHotDataSchema'
import { excelRound } from '../shared/dc-validator/utils/excelRound'
import { isEmpty } from '../shared/dc-validator/utils/isEmpty'
import { hasFormulaRules } from '../shared/dc-validator/utils/hasFormulaRules'
import { getFormulaColumnNames } from '../shared/dc-validator/utils/getFormulaColumnNames'
import { isCharacterColumn } from '../shared/dc-validator/utils/isCharacterColumn'
import { escapeCharacterColumnValue } from '../shared/dc-validator/utils/escapeCharacterColumnValue'
import { unescapeFormula } from '../shared/dc-validator/utils/unescapeFormula'
import {
AutoEscapedCellMap,
getRowKey,
markAutoEscaped,
isAutoEscaped,
clearAutoEscaped
} from '../shared/dc-validator/utils/autoEscapedCellTracker'
import { HyperFormula } from 'hyperformula'
import { parseLabelsParam } from '../shared/utils/parse-labels-param'
import { getDisplayColHeaders } from '../shared/utils/display-col-headers'
import { buildColInfoHtml } from '../shared/utils/col-info-html'
import { globals } from '../_globals'
import { UploadStaterComponent } from './components/upload-stater/upload-stater.component'
import {
DynamicCellValidation,
DynamicExtendedCellValidation
} from './models/dynamicExtendedCellValidation'
import { EditRecordInputFocusedEvent } from './models/edit-record/edit-record-events'
import { EditorRestrictions } from './models/editor-restrictions.model'
import { parseTableColumns } from './utils/grid.utils'
import { classifyRow } from './utils/classifyRow'
import { withoutEditStatus } from './utils/withoutEditStatus'
import { getEditStatusSymbol } from './utils/getEditStatusSymbol'
import { normalizeSortConfig } from './utils/normalizeSortConfig'
import { EDIT_STATUS_COLUMN_NAME } from '../shared/dc-validator/utils/editStatusColumnRule'
import {
errorRenderer,
noSpinnerRenderer,
spinnerRenderer
} from './utils/renderers.utils'
import { LicenceService } from '../services/licence.service'
import { FileUploadEncoding } from '../models/FileUploadEncoding'
import { SpreadsheetService } from '../services/spreadsheet.service'
import { VaMessagingService, VaMessage } from '../services/va-messaging.service'
import { VaFilterService } from '../services/va-filter.service'
import { UploadFileResponse } from '../models/UploadFile'
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
import { ParseResult } from '../models/ParseResult.interface'
@Component({
selector: 'app-editor',
templateUrl: './editor.component.html',
styleUrls: ['./editor.component.scss'],
host: {
class: 'content-container'
},
encapsulation: ViewEncapsulation.None,
standalone: false
})
export class EditorComponent
implements OnInit, AfterViewInit, AfterViewChecked, OnDestroy
{
@ViewChildren('uploadStater')
uploadStaterCompList: QueryList<UploadStaterComponent> = new QueryList()
@ViewChildren('queryFilter')
queryFilterCompList: QueryList<QueryComponent> = new QueryList()
@ViewChild(HotTableComponent, { static: false })
hotTableComponent!: HotTableComponent
@ViewChildren('fileUploadInput')
fileUploadInputCompList: QueryList<ElementRef> = new QueryList()
public static cnt = 0
public static nonPkCnt = 0
public static lastCell = 0
private _tableSub: Subscription | undefined
public message = ''
public $dataFormats: $DataFormats | null = null
public submit: boolean | undefined
public cols: Col[] = []
@ViewChild('ht', { static: true }) ht!: ElementRef
/** Feature restrictions
*
* What can be restricted
* - Add Row button
* - Insert rows above/below
* - Add record button
* - Edit record button
*
* Types of limitations ordered by priority of enforcement (Restrictions upper on the list cannot be un-restricted by lower types)
* - Restrict edit record feature with config in startupservice (comes from appService)
* - Restrict `edit record feature` and `add row` if demo and demo limits set as such - since demo is limited to less rows, and buttons which adds rows triggers error
* - Restrict `add record feature` and `add row` based on configuration of `Column Level Security`
*/
restrictions: EditorRestrictions = {}
datasetInfo = false
dsmeta: DSMeta[] = []
versions: Version[] = []
dsNote = ''
viewboxes = false
Infinity = Infinity
public hotInstance!: Handsontable
public dcValidator: DcValidator | undefined
public hotTableSettings: Handsontable.GridSettings = {}
private updateHotTableSettings(): void {
this.hotTableSettings = {
colHeaders: this.hotTable.colHeaders,
columns: this.hotTable.columns,
height: this.hotTable.height,
licenseKey: this.hotTable.licenseKey,
readOnly: this.hotTable.readOnly,
copyPaste: this.hotTable.copyPaste,
// Must carry the current formulas config through - this rebuilds
// hotTableSettings (and re-applies it via the [settings] binding)
// whenever hot_license_key emits, which can happen after a table has
// already loaded and turned formulas on; omitting the key here would
// let that rebuild silently drop it again.
formulas: this.hotTable.formulas,
contextMenu: true,
className: 'htDark',
theme: 'ht-theme-classic'
}
}
public hotTable: HotTableInterface = {
data: [],
colHeaders: [],
hidden: true,
columns: [],
height: 'calc(100vh - 160px)',
licenseKey: undefined,
readOnly: true,
// Every table gets the formulas engine, regardless of whether it
// declares any HARDFORMULA/SOFTFORMULA rules - set here (not only
// inside initSetup) so it's already present in hotTableSettings for
// the very first [settings] binding, before any table has loaded.
formulas: { engine: HyperFormula, licenseKey: 'gpl-v3' },
copyPaste: {
copyColumnHeaders: true,
copyColumnHeadersOnly: true
},
settings: {
contextMenu: {
items: {
edit_row: {
name: 'Edit row',
// HOT 18's MenuItemConfig types `hidden` as a plain `() => boolean` with
// no `this` type, so an object-literal method here has `this` inferred
// as the surrounding item config unless declared explicitly.
hidden(this: Handsontable.Core) {
const hot = this
// Hide editing actions in read-only (view) mode.
if (hot.getSettings().readOnly) return true
const fullCellRange: CellRange[] | undefined =
hot.getSelectedRange()
if (!fullCellRange) return false
const cellRange = fullCellRange[0]
return cellRange.from.row !== cellRange.to.row
},
callback: (
key: string,
selection: any[],
clickEvent: MouseEvent
) => {
const firstSelection = selection[0]
if (firstSelection.start.row === firstSelection.end.row) {
this.editRecord(null, firstSelection.start.row)
}
}
},
// Only ever shown when the selection contains at least one cell
// markOverwrittenCells (or the live afterChange sync) attached a
// comment to - i.e. a cell whose current value differs from what
// SAS actually sent for it, formula-caused or a direct edit. The
// comment's presence is a sufficient and exact signal, no need to
// separately recompute "is this overwritten" here too. The
// 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) {
if (this.getSettings().readOnly) return true
const ranges: CellRange[] | undefined = this.getSelectedRange()
if (!ranges || ranges.length === 0) return true
const commentsPlugin: any = this.getPlugin('comments')
const cells = expandCellRanges(
ranges,
this.countRows(),
this.countCols()
)
return !cells.some(({ row, col }) =>
commentsPlugin.getCommentAtCell(row, col)
)
},
callback: (key: string, selection: any[]) => {
const hot = this.hotInstance
const commentsPlugin: any = hot.getPlugin('comments')
const cells = expandCellRanges(
selection.map((sel) => ({ from: sel.start, to: sel.end })),
hot.countRows(),
hot.countCols()
)
for (const { row, col } of cells) {
const comment: string | undefined =
commentsPlugin.getCommentAtCell(row, col)
if (!comment) continue
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,
resolveRevertedCellValue(rawValueText, isNumericCol),
'revert'
)
commentsPlugin.removeCommentAtCell(row, col)
}
}
},
// Only ever shown when the selection contains at least one cell
// this app itself auto-escaped (see character-column-formula-
// plan.md) - a genuinely literal leading `'` the backend sent, or
// one the user typed themselves, is never in autoEscapedCells and
// so never makes this item appear or gets touched by it.
apply_as_formula: {
name: 'Apply as formula',
hidden: (): boolean => {
const hot = this.hotInstance
if (hot.getSettings().readOnly) return true
const ranges: CellRange[] | undefined = hot.getSelectedRange()
if (!ranges || ranges.length === 0) return true
const cells = expandCellRanges(
ranges,
hot.countRows(),
hot.countCols()
)
return !cells.some(({ row, col }) => {
const dataRow = this.dataSource[hot.toPhysicalRow(row)]
if (!dataRow) return false
const colName = hot.colToProp(col) as string
return isAutoEscaped(
this.autoEscapedCells,
getRowKey(dataRow, this.headerPks),
colName
)
})
},
callback: (key: string, selection: any[]) => {
const hot = this.hotInstance
const cells = expandCellRanges(
selection.map((sel) => ({ from: sel.start, to: sel.end })),
hot.countRows(),
hot.countCols()
)
for (const { row, col } of cells) {
const dataRow = this.dataSource[hot.toPhysicalRow(row)]
if (!dataRow) continue
const colName = hot.colToProp(col) as string
const rowKey = getRowKey(dataRow, this.headerPks)
if (!isAutoEscaped(this.autoEscapedCells, rowKey, colName))
continue
hot.setDataAtRowProp(
row,
colName,
unescapeFormula(hot.getDataAtRowProp(row, colName))
)
clearAutoEscaped(this.autoEscapedCells, rowKey, colName)
}
}
},
row_above: {
name: 'Insert Row above',
hidden: () => this.hotTable.readOnly === true,
callback: (
key: string,
selection: any[],
clickEvent: MouseEvent
) => {
const firstSelection = selection[0]
const targetRow = firstSelection.start.row
this.insertRowAtPosition(targetRow)
}
},
row_below: {
name: 'Insert Row below',
hidden: () => this.hotTable.readOnly === true,
callback: (
key: string,
selection: any[],
clickEvent: MouseEvent
) => {
const firstSelection = selection[0]
const targetRow = firstSelection.start.row + 1
this.insertRowAtPosition(targetRow)
}
},
remove_row: {
name: 'Ignore row',
hidden: () => this.hotTable.readOnly === true
},
copy: {
name: 'Copy without headers'
},
copy_with_column_headers: {
name: 'Copy with headers'
},
copy_column_headers_only: {
name: 'Copy headers only'
},
// Client-side export of the current grid view (or selection).
// skipLeadingCols=1 drops the `Delete?` housekeeping column (col 0).
export_file: buildExportMenuItem(() => this.libds || 'export', 1),
sp1: {
name: '---------',
hidden: () => this.hotTable.readOnly === true
},
undo: {
name: 'Undo',
hidden: () => this.hotTable.readOnly === true
},
redo: {
name: 'Redo',
hidden: () => this.hotTable.readOnly === true
},
// Navigates rather than just flipping `useLabels` locally, so the
// URL stays the single source of truth (shareable/refreshable
// state) — the queryParams subscription in ngOnInit picks up the
// change and re-renders colHeaders.
toggle_labels: {
name: () => (this.useLabels ? 'Show names' : 'Show labels'),
callback: () => {
this.router.navigate([], {
relativeTo: this.route,
queryParams: { labels: this.useLabels ? null : true },
queryParamsHandling: 'merge'
})
}
}
}
}
}
}
public hotCellsPropRow: number | null = null
public filter = false
public submitLoading = false
public uploadLoading = false
public rowsChanged: any = {
rowsUpdated: 0,
rowsDeleted: 0,
rowsAdded: 0
}
public modifedRowsIndexes: number[] = []
public queryErr = false
public queryErrMessage: string | undefined
public successEnable = false
public libTab: string | undefined
public queryFilter: any
public _query: Subscription | undefined
private _queryParams: Subscription | undefined
// `?labels=true` toggle (see parseLabelsParam) — URL is the source of
// truth, kept in sync via the queryParams subscription in ngOnInit.
public useLabels: boolean = false
public whereString: string | undefined
public clauses: any
public nullVariables = false
public tableId: string | undefined
public pkFields: any = []
public libds: string | undefined
public filter_pk: string | undefined
public table: any
public filename = ''
public selectedColumn: any
public hotSelection: Array<number> | null | undefined
public submitLimitNotice = false
public badEdit = false
public badEditCause: string | undefined
public badEditTitle: string | undefined
get embed() {
return globals.embed
}
/** True when running as a SAS Visual Analytics data-driven content object. */
get isVaEmbed() {
return globals.embed === 'va'
}
/**
* VA filter mode. Live = auto-apply each VA change; confirm = stage the change
* and wait for the user to click Apply. Backed by globals (toggled by the
* Auto-apply checkbox) so it persists across the editor reloads a filter apply
* triggers. Defaults to 'live'.
*/
get vaAutoApply(): boolean {
return globals.vaApplyMode !== 'confirm'
}
/**
* VA filter UI status (shown in both modes):
* - 'pending' : a VA change was received and not yet fetched (debouncing in
* live mode, or awaiting Apply in confirm mode);
* - 'loading' : the filter is being fetched (saveQuery), just before DC's
* native "Loading Table" takes over for the data;
* - 'idle' : nothing pending.
*/
public vaFilterStatus: 'idle' | 'pending' | 'loading' = 'idle'
/** The staged clauses/signature for the pending VA filter. */
private vaPendingClauses: QueryClause[] | null = null
private vaPendingSignature = ''
/** Removes the VA postMessage listener; set while subscribed. */
private vaUnsubscribe?: () => void
/** Guards against overlapping VA filter reloads. */
private vaApplyingFilter = false
/** Debounce timer coalescing a burst of VA messages into one apply. */
private vaDebounceTimer?: ReturnType<typeof setTimeout>
/**
* Debounce window (ms) for VA messages. Sized so a burst of rapid control
* clicks coalesces into a single apply (the last one) BEFORE any filter
* reload. Note VA itself also throttles how often it posts, so this stacks
* with that cadence.
*/
private static readonly VA_DEBOUNCE_MS = 800
// Shared between markOverwrittenCells/syncOverwrittenCommentForCell
// (writes it) and the revert_cells context menu item (parses it back
// out) - see findOverwrittenCells.
private static readonly ORIGINAL_VALUE_COMMENT_PREFIX = 'Original value: '
public tableTrue: boolean | undefined
public saveLoading = false
public approvers: string[] = []
public approver: any
public readOnlyFields!: number
public errValidation = false
public dataObj: any
public disableSubmit: boolean | undefined
public pkNull = false
public noPkNull = false
public tableData: Array<any> = []
public queryText = ''
public queryTextSaved = ''
/**
* The applied-filter panel is collapsed to a single line by default. The
* chevron that expands it is only useful when the clause does not fit that
* line, which depends on the rendered width - so it is measured from the DOM
* rather than guessed from the length of the text.
*/
public filterExpanded = false
public filterOverflows = false
@ViewChild('infoBar') infoBar?: ElementRef<HTMLElement>
private filterMeasuredKey = ''
public showApprovers = false
public pkDups = false
public validationDone = 0
public duplicatePkIndexes: any = []
public columnHeader: string[] = []
public specInfo: { col: string; len: number; type: number }[] = []
public tooLong = false
public exceedCells: {
col: string
len: number
val: string
}[] = []
public uploader: FileUploader = new FileUploader()
public uploadUrl = ''
public excelFileReady = false
public uploadPreview = false
public excelFileParsing = false
public excelUploadState: string | null = null
public data: AOA = []
public headerArray: string[] = []
public hotDataSchema: any = {}
public headerShow: string[] = []
public headerVisible = false
public hasBaseDropZoneOver = false
public hasAnotherDropZoneOver = false
public headerPks: string[] = []
public columnLevelSecurityFlag = false
public dateTimeHeaders: string[] = []
public timeHeaders: string[] = []
public dateHeaders: string[] = []
public xlRules: ExcelRule[] = []
public encoding: FileUploadEncoding = 'UTF-8'
// header column names
headerColumns: Array<any> = []
cellValidation: DcValidation[] = []
// hot table data source
dataSource!: any[]
prevDataSource!: any[]
dataSourceUnchanged!: any[]
// Raw, as-received-from-SAS snapshot, captured before applyFormulaRules
// overwrites HARDFORMULA/SOFTFORMULA columns - see findOverwrittenCells
// and getRevertableColumnNames. Distinct from dataSourceUnchanged, which is a
// per-editing-session baseline that gets reset on every editTable() call;
// this stays fixed for the table's whole lifetime, since "what did the
// real dataset actually have before any formula got involved" doesn't
// change across edit sessions.
dataSourceRaw!: any[]
dataSourceBeforeSubmit!: any[]
dataModified!: any[]
// Cells the app itself auto-escaped (see escapeCharacterColumnValue) -
// PK-keyed, not a property on the row objects, so it can never leak into
// the submit payload and survives insert/delete/sort. Reset on every
// fresh load, same lifecycle as dataSourceRaw/dataSourceUnchanged. See
// character-column-formula-plan.md.
private autoEscapedCells: AutoEscapedCellMap = new Map()
public filePasswordSubject: Subject<string | undefined> = new Subject()
public fileUnlockError = false
public filePasswordModal = false
public showUploadModal = false
public discardSourceFile = false
public manualFileEditModal = false
public recordAction: string | null = null
public currentEditRecord: any
public currentEditRecordValidator: DcValidator | undefined
public currentEditRecordLoadings: number[] = []
public currentEditRecordErrors: number[] = []
public currentEditRecordIndex = -1
public generateEditRecordUrlLoading = false
public generatedRecordUrl: string | null = null
public addRecordUrl: string | null = null
public recordNewOrPkModified = false
public addRecordLoading = false
public singleRowSelected = false
public addingNewRow = false
public getdataError = false
public zeroFilterRows = false
public tableFileDragOver = false
/**
* Hash/values table used for dynamic cell validation
*/
public cellValidationSource: CellValidationSource[] = []
public validationTableLimit = 100
// Incremented on cancel/edit-exit so in-flight dynamic-validation
// responses can detect they should drop their post-response work.
private validationEpoch = 0
// Cells currently showing the loading spinner renderer (keyed `r,c`),
// so cancelBulkValidation can reset them.
private pendingSpinnerCells = new Set<string>()
// State for the bulk-validation progress banner (paste / autofill).
public bulkValidation: {
active: boolean
done: number
total: number
} = { active: false, done: 0, total: 0 }
// Confirm-modal state used to gate large paste validations.
public confirmModal: {
open: boolean
title: string
message: string
} = { open: false, title: '', message: '' }
private confirmModalResolver: ((v: boolean) => void) | null = null
public disabledBasicDynamicCellValidationMap: {
row: number
col: number
active: boolean
}[] = []
public licenceState = this.licenceService.licenceState
private ariaObserver: MutationObserver | undefined
private ariaCheckInterval: any | undefined
private gridResizeObserver: ResizeObserver | undefined
private pasteListener: ((event: ClipboardEvent) => void) | undefined
constructor(
private licenceService: LicenceService,
private eventService: EventService,
private loggerService: LoggerService,
private sasStoreService: SasStoreService,
private helperService: HelperService,
private router: Router,
private route: ActivatedRoute,
private sasService: SasService,
private cdf: ChangeDetectorRef,
private spreadsheetService: SpreadsheetService,
private vaMessaging: VaMessagingService,
private vaFilter: VaFilterService,
private userService: UserService
) {
this.parseRestrictions()
this.setRestrictions()
}
/**
* Prepare feature restrictions based on licence key
*/
private parseRestrictions() {
this.restrictions.restrictAddRecord =
this.licenceState.value.addRecord === false
this.restrictions.restrictEditRecord =
this.licenceState.value.editRecord === false
this.restrictions.restrictFileUpload =
this.licenceState.value.fileUpload === false
}
/**
* Applying prepared restrictions
* @param overrideRestrictions can be used to apply and override specific restrictions
*/
private setRestrictions(overrideRestrictions?: EditorRestrictions) {
if (overrideRestrictions) {
this.restrictions = {
...this.restrictions,
...overrideRestrictions
}
}
if (this.restrictions.removeEditRecordButton) {
delete (this.hotTable?.settings?.contextMenu as any).items.edit_row
}
if (this.restrictions.restrictAddRow) {
delete (this.hotTable?.settings?.contextMenu as any).items.row_above
delete (this.hotTable?.settings?.contextMenu as any).items.row_below
delete (this.hotTable?.settings?.contextMenu as any).items.remove_row
}
}
/**
* Disabling add row button based on wether rows limit is present
*/
private checkRowLimit() {
if (this.columnLevelSecurityFlag) return
if (this.licenceState.value.editor_rows_allowed !== Infinity) {
if (
this.dataSource?.length >= this.licenceState.value.editor_rows_allowed
) {
this.restrictions.restrictAddRow = true
} else {
this.restrictions.restrictAddRow = false
}
}
}
/**
* Resetting filter variables
*/
public resetFilter() {
if (this.queryFilterCompList.first) {
this.queryFilterCompList.first.resetFilter()
}
}
/**
* Openning file upload modal
* If feature is locked, `feature locked` modal will be shown
*/
public onShowUploadModal() {
if (this.restrictions.restrictFileUpload) {
this.eventService.showDemoLimitModal('File Upload')
return
}
if (this.columnLevelSecurityFlag) {
this.eventService.showInfoModal(
'Information',
'Upload feature is disabled while Column Level Security rules are active'
)
return
}
if (!this.uploadPreview) this.showUploadModal = true
}
/**
* Called by FileDropDirective
* @param e true if file is dragged over the drop zone
*/
public fileOverBase(e: boolean): void {
this.hasBaseDropZoneOver = e
}
public attachFile(event: any, dropped = false) {
const file: File = dropped ? event[0] : event.target.files[0]
this.excelUploadState = 'Loading'
this.excelFileParsing = true
this.excelFileReady = false
this.filename = file.name
this.spreadsheetService
.parseExcelFile(
{
file: file,
uploader: this.uploader,
dcValidator: this.dcValidator!,
headerPks: this.headerPks,
headerArray: this.headerArray,
headerShow: this.headerShow,
timeHeaders: this.timeHeaders,
dateHeaders: this.dateHeaders,
dateTimeHeaders: this.dateTimeHeaders,
xlRules: this.xlRules,
encoding: this.encoding
},
(uploadState: string) => {
this.appendUploadState(uploadState)
},
(tableFoundInfo: string) => {
this.eventService.showInfoModal('Table Found', tableFoundInfo)
}
)
.then(async (parseResult: ParseResult | undefined) => {
if (parseResult) {
this.excelFileReady = true
this.uploader = parseResult.uploader
if (parseResult.data && parseResult.headerShow) {
// If data is returned it means we parsed excel file
this.data = parseResult.data
this.headerShow = parseResult.headerShow
this.getPendingExcelPreview()
} else {
// otherwise it's csv file, and we send them directly
await this.uploadParsedFiles()
}
}
})
.catch((error: string) => {
this.eventService.showAbortModal(null, error, null)
this.showUploadModal = false
this.uploadPreview = false
setTimeout(() => {
this.filename = ''
})
})
.finally(() => {
this.excelFileParsing = false
})
}
/**
* Submits attached excel file that is in preview mode
*/
public submitExcel() {
if (this.licenceState.value.submit_rows_limit !== Infinity) {
this.submitLimitNotice = true
return
}
this.uploadParsedFiles()
}
/**
* This method will run validations and upload all of the pending files
* that are in the uploader queue.
*/
public async uploadParsedFiles() {
if (this.checkInvalid()) {
this.eventService.showAbortModal(null, 'Invalid values are present.')
return
}
this.validatePrimaryKeys()
if (this.duplicatePkIndexes.length !== 0) {
this.pkDups = true
this.submit = false
return
} else {
this.pkDups = false
}
this.uploadLoading = true
const filesToUpload: UploadFile[] = []
for (const file of this.uploader.queue) {
filesToUpload.push({
file: file,
fileName: file.name
})
}
await this.sasService
.uploadFile(this.uploadUrl, filesToUpload, { table: this.libds })
.then(
(res: UploadFileResponse) => {
if (typeof res.adapterResponse.sasjsAbort === 'undefined') {
if (typeof res.adapterResponse.sasparams === 'undefined') {
return
} else {
this.uploadLoading = false
const params = res.adapterResponse.sasparams[0]
this.successEnable = true
this.tableId = params.DSID
this.router.navigateByUrl('/stage/' + this.tableId)
}
} else {
// handle succesfull response
const abortRes = res.adapterResponse
const abortMsg = abortRes.sasjsAbort[0].MSG
const macMsg = abortRes.sasjsAbort[0].MAC
this.uploadLoading = false
this.filename = ''
if (this.fileUploadInputCompList.first) {
//clear the attached file to input
this.fileUploadInputCompList.first.nativeElement.value = ''
}
this.uploader.queue = []
this.eventService.showAbortModal('', abortMsg, {
SYSWARNINGTEXT: abortRes.SYSWARNINGTEXT,
SYSERRORTEXT: abortRes.SYSERRORTEXT,
MAC: macMsg,
_PROGRAM: abortRes._PROGRAM
})
}
},
(err: any) => {
this.uploadLoading = false
if (this.fileUploadInputCompList.first) {
//clear the attached file to input
this.fileUploadInputCompList.first.nativeElement.value = ''
}
this.uploader.queue = []
this.eventService.catchResponseError(
'file upload',
err.adapterResponse
)
}
)
}
/**
* After excel file is attached and parsed, this function will display it's content in the HOT table in read only mode
*/
public getPendingExcelPreview() {
this.queryTextSaved = this.queryText
this.queryText = ''
this.excelUploadState = 'Parsing'
this.toggleHotPlugin('contextMenu', false)
const previewDatasource: any[] = []
this.data.map((item) => {
const itemObject: any = {}
this.headerShow.map((header: any, index: number) => {
itemObject[header] = item[index]
})
// If Delete? column is not set in the file, we set it to NO
if (!itemObject['_____DELETE__THIS__RECORD_____'])
itemObject['_____DELETE__THIS__RECORD_____'] = 'No'
// EDIT_STATUS is never part of the uploaded file (client-only, see
// editStatusColumnRule.ts) - default it the same as a fresh load.
itemObject[EDIT_STATUS_COLUMN_NAME] = 'U'
previewDatasource.push(itemObject)
})
// An uploaded file is bulk, external data - not something the user
// typed/pasted cell-by-cell - so a `=`-led value in a character column
// must be escaped to inert text, the same way a fresh backend load is
// (see initSetup's own escape loop). Formulas are enabled for every
// table, so without this a spreadsheet cell that merely looks like a
// formula (an inventory code, a serial number) would silently start
// evaluating as a live one.
const formulaBaseCols = getFormulaColumnNames(
this.dcValidator?.getDqDetails() ?? []
)
this.autoEscapedCells = new Map()
for (const row of previewDatasource) {
const rowKey = getRowKey(row, this.headerPks)
for (const colName of this.headerColumns) {
if (formulaBaseCols.includes(colName)) continue
if (!isCharacterColumn(this.cols, colName)) continue
const { value, wasEscaped } = escapeCharacterColumnValue(row[colName])
// A row whose backend source never had this column at all (a
// sparse row) must stay that way - writing back unconditionally
// would materialize a new own property set to undefined, which
// the SASjs adapter rejects at submit time even though nothing
// about this cell ever changed.
if (!wasEscaped) continue
row[colName] = value
markAutoEscaped(this.autoEscapedCells, rowKey, colName)
}
}
this.dataSourceUnchanged = this.helperService.deepClone(this.dataSource)
this.dataSource = previewDatasource
this.hotTable.data = previewDatasource
const hot = this.hotInstance
this.excelUploadState = 'Validating-HOT'
this.updateSettingsSortSafe(
{
data: this.dataSource,
maxRows: Infinity
},
false
)
hot.render()
this.appendUploadState(`Validating rows`)
hot.validateCells(() => {
this.showUploadModal = false
this.uploadPreview = true
this.excelFileParsing = false
this.excelUploadState = null
})
}
/**
* Drops the attached excel file
* @param discardData wheter to discard data parsed from the file or to keep it in the table after dropping a attached excel file
*/
public discardPendingExcel(discardData?: boolean) {
this.updateSettingsSortSafe({
maxRows: this.licenceState.value.editor_rows_allowed
})
if (discardData) this.cancelEdit()
if (this.fileUploadInputCompList.first) {
this.fileUploadInputCompList.first.nativeElement.value = ''
}
this.uploadPreview = false
this.excelFileReady = false
this.uploader.queue = []
if (!isNaN(parseInt(this.router.url.split('/').pop() || ''))) {
if (this.queryTextSaved.length > 0) {
this.queryText = this.queryTextSaved
this.queryTextSaved = ''
}
}
}
/**
* Drops attached excel file, keeps it's data in the DC table
* User can now edit the table and submit. Witout the file present.
*/
public previewTableEditConfirm() {
this.discardPendingExcel()
this.convertToCorrectTypes(this.dataSource)
this.editTable(true)
}
private appendUploadState(state: string, replaceLast = false) {
this.cdf.detectChanges()
if (this.uploadStaterCompList.first) {
if (replaceLast) {
this.uploadStaterCompList.first.replaceLastState(state)
} else {
this.uploadStaterCompList.first.appendState(state)
}
}
}
isColPk(col: string) {
return this.headerPks.indexOf(col) > -1
}
isReadonlyCol(col: string | number) {
const colRules = this.dcValidator?.getRule(col)
return colRules?.readOnly
}
isColHeader(col: string) {
return this.headerArray.indexOf(col.toUpperCase()) > -1
}
removeQuery() {
this.sasStoreService.removeClause()
}
async sendClause() {
this.submitLoading = true
let nullVariableArr = []
const emptyVariablesArr = []
// to check number of empty clauses
if (typeof this.clauses === 'undefined') {
this.nullVariables = true
this.submitLoading = false
return
} else {
const query = this.clauses.queryObj
if (query[0].elements.length < 1) {
// Clear cached filtering data
if (globals.rootParam === 'home' || globals.rootParam === 'editor') {
globals.editor.filter.clauses = []
globals.editor.filter.query = []
globals.editor.filter.groupLogic = ''
}
// Reset filtering
this.router.navigate(['/editor/' + this.libds], {
queryParamsHandling: 'preserve'
})
return
}
for (let index = 0; index < query.length; index++) {
const el = query[index].elements
nullVariableArr = el.filter(function (item: any) {
return item.variable === null
})
if (nullVariableArr.length) {
emptyVariablesArr.push(el)
}
}
}
if (emptyVariablesArr.length) {
this.nullVariables = true
this.submitLoading = false
return
} else {
try {
if (this.clauses !== undefined && this.libds) {
const filterQuery: FilterQuery = {
groupLogic: this.clauses.groupLogic,
filterGroups: []
}
this.clauses.queryObj.forEach((group: any) => {
const filterGroup: FilterGroup = {
filterClauses: []
}
group.elements.forEach((clause: any) => {
filterGroup.filterClauses.push(
this.helperService.deepClone(clause)
)
})
filterGroup.clauseLogic = group.clauseLogic
filterQuery.filterGroups.push(
this.helperService.deepClone(filterGroup)
)
})
const filterQueryClauseTable =
this.sasStoreService.createFilterQueryTable(filterQuery)
await this.sasStoreService
.saveQuery(this.libds, filterQueryClauseTable)
.then((res: any) => {
const id = res.result[0].FILTER_RK
const table = res.result[0].FILTER_TABLE
this.queryFilter = { id: id, table: table }
this.router
.navigate(['/'], {
skipLocationChange: true,
queryParamsHandling: 'preserve'
})
.then(() =>
this.router.navigate(
[
'/editor/' +
this.queryFilter.table +
'/' +
this.queryFilter.id
],
{
queryParamsHandling: 'preserve'
}
)
)
this.filter = false
})
.catch((err: any) => {
this.submitLoading = false
})
}
} catch (error: any) {
this.queryErr = true
this.submitLoading = false
this.queryErrMessage = error
}
}
}
public openQb() {
if (this.libds) {
// this.libTab = this.libds;
this.filter = true
this.cdf.detectChanges()
this.submitLoading = false
// The columns that came with the table are the cheapest source for the
// picker. When the table was not returned - the row/cell limits abort the
// request before the data arrives - that list is empty, so the store
// falls back to public/getcols rather than leaving the picker with
// nothing to pick from.
this.sasStoreService
.setQueryVariablesFromTable(this.libds, this.cols)
.catch((err: any) =>
this.eventService.catchResponseError('public/getcols', err)
)
}
}
/**
* Reads the current multi-column sort config defensively. The sorting plugin
* may not be initialised yet (e.g. editTable() invoked right after load in VA
* embed mode, before the grid finished setup), in which case getSortConfig()
* throws on null internal state. There's no sort to preserve then, so [].
*/
private getCurrentSortConfigs(): any[] {
const hot = this.hotInstance
if (!hot) return []
try {
const plugin = hot.getPlugin('multiColumnSorting')
return normalizeSortConfig(plugin?.getSortConfig())
} catch {
return []
}
}
/**
* Wraps hot.updateSettings() to work around a genuine Handsontable
* multiColumnSorting + formulas plugin bug: calling updateSettings() -
* with ANY settings, even a bare {} - while a sort is active corrupts
* formula cell references, rendering '#REF!' instead of their computed
* value (reproduced directly: a bare {} settings object alone is enough
* to trigger it, independent of what it actually changes). Clearing the
* sort first and restoring it immediately afterward avoids the
* corruption entirely. Not needed for the very first updateSettings()
* call at initial table load, since nothing can be sorted yet then.
*
* editTable()/cancelEdit() deliberately do NOT call this wrapper - they
* manage their own clear/restore inline, because their restore point is
* later in the method (after other logic like syncOverwrittenComments()),
* not immediately after their own updateSettings() call the way this
* wrapper restores it. Don't "simplify" those two to use this instead
* without preserving that later restore point.
*/
private updateSettingsSortSafe(
settings: Handsontable.GridSettings,
render?: boolean
): void {
const hot = this.hotInstance
const columnSorting = hot.getPlugin('multiColumnSorting')
const sortConfigs = this.getCurrentSortConfigs()
if (sortConfigs.length > 0) columnSorting.clearSort()
hot.updateSettings(settings, render)
// sort() replaces the entire sort state on every call rather than
// accumulating (see its own JSDoc) - restoring a multi-column sort
// needs one call with the whole array, not a loop of single-column
// calls, or every column but the last silently loses its sort.
if (sortConfigs.length > 0) columnSorting.sort(sortConfigs)
}
editTable(previewEdit?: boolean, newRow?: boolean) {
this.toggleHotPlugin('contextMenu', true)
const hot = this.hotInstance
if (!hot) return
// Entering edit mode: cancel any scheduled VA filter apply so it can't fire
// mid-edit and wipe the edits. A pending filter is re-applied on the return
// to read-only (cancelEdit).
if (this.vaDebounceTimer) {
clearTimeout(this.vaDebounceTimer)
this.vaDebounceTimer = undefined
}
const columnSorting = hot.getPlugin('multiColumnSorting')
const sortConfigs = this.getCurrentSortConfigs()
setTimeout(() => {
if (!previewEdit) {
this.dataSourceUnchanged = this.helperService.deepClone(this.dataSource)
if (newRow) {
this.dataSourceUnchanged.pop()
}
this.overlayFormulaRawValuesOnUnchanged(this.dataSourceUnchanged)
}
this.hotTable.readOnly = false
this.hotTable.data = this.dataSource
// Handsontable's multiColumnSorting + formulas integration corrupts
// formula cell references (renders '#REF!') if updateSettings() runs
// while a sort is still active, even for an unrelated setting -
// clear it first and restore the captured sortConfigs afterward
// (below), rather than leaving the sort applied across the call.
if (sortConfigs.length > 0) columnSorting.clearSort()
hot.updateSettings(
{
readOnly: this.hotTable.readOnly
},
false
)
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()
// sort() replaces the entire sort state on every call rather than
// accumulating (see its own JSDoc) - a multi-column sort needs one
// call with the whole array, not a loop of single-column calls, or
// every column but the last silently loses its sort.
if (sortConfigs.length > 0) columnSorting.sort(sortConfigs)
this.reSetCellValidationValues()
// Fix ARIA accessibility issues after table edit
setTimeout(() => {
this.fixAriaAccessibility()
}, 100)
}, 0)
}
convertToCorrectTypes(dataSource: any) {
for (const row of dataSource) {
for (const colKey in row) {
const colSpecs = this.cols.find((x: any) => x.NAME === colKey)
if (colSpecs) {
if (
row[colKey] !== '' &&
colSpecs.TYPE === 'num' &&
!colSpecs.DDTYPE.includes('TIME') &&
!colSpecs.DDTYPE.includes('DATE')
)
row[colKey] = parseInt(row[colKey])
}
}
}
}
cancelEdit() {
this.cancelBulkValidation({ revert: false })
// Keep the context menu enabled after leaving edit mode (view mode keeps
// Copy/Export; editing items hide themselves when read-only).
this.toggleHotPlugin('contextMenu', true)
this.cellValidationSource = []
// Clear custom validation styling
this.clearDuplicateValidation()
const hot = this.hotInstance
const columnSorting = hot.getPlugin('multiColumnSorting')
const sortConfigs = this.getCurrentSortConfigs()
// Cleared here, before anything below reads a row by index - both the
// getFormulaCellsToPreserveOnCancel callbacks just below (physical
// rowIndex passed straight to visual-row-expecting Handsontable APIs)
// and the later updateSettings() call (see editTable()'s own comment -
// corrupts formula cell references while a sort is active) need the
// grid unsorted. Restored below, after the re-render, from the
// sortConfigs captured here.
if (sortConfigs.length > 0) columnSorting.clearSort()
if (this.dataSourceUnchanged) {
// dataSourceUnchanged deliberately holds the RAW pre-formula value for
// a HARDFORMULA/SOFTFORMULA column that still has its
// markOverwrittenCells comment (see editTable's overlay) - needed
// so classifyRow flags the row as modified, but it isn't a session
// edit to discard: the formula recomputes on every load regardless.
// Snapshot those cells' live computed value before the blind restore
// below, then patch them back in - otherwise cancelling would freeze
// the display at the raw value and erase the row's modified marker.
const formulaBaseCols = this.getFormulaBaseCols()
const commentsPlugin = hot.getPlugin('comments')
const toPreserve = getFormulaCellsToPreserveOnCancel(
this.dataSource.length,
formulaBaseCols,
(rowIndex, baseCol) =>
!!commentsPlugin.getCommentAtCell(
rowIndex,
hot.propToCol(baseCol) as number
),
(rowIndex, prop) => hot.getDataAtRowProp(rowIndex, prop)
)
this.dataSource = this.helperService.deepClone(this.dataSourceUnchanged)
for (const cell of toPreserve) {
if (this.dataSource[cell.rowIndex]) {
this.dataSource[cell.rowIndex][cell.prop] = cell.value
}
}
}
this.hotTable.data = this.dataSource
this.hotTable.readOnly = true
// Sort was already cleared above (before the getFormulaCellsToPreserveOnCancel
// callbacks); still cleared here going into updateSettings() - see
// editTable()'s own comment: it corrupts formula cell references
// ('#REF!') while a sort is active. Restored below via sortConfigs.
hot.updateSettings(
{
readOnly: this.hotTable.readOnly,
data: this.dataSource
},
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 = []
hot.validateCells()
// this.editRecordListeners();
// sort() replaces the entire sort state on every call rather than
// accumulating (see its own JSDoc) - a multi-column sort needs one
// call with the whole array, not a loop of single-column calls, or
// every column but the last silently loses its sort.
if (sortConfigs.length > 0) columnSorting.sort(sortConfigs)
this.checkRowLimit()
// Back to read-only: apply any VA filter change that was held pending while
// editing (live mode). Confirm mode leaves it staged for the Apply button.
if (
this.isVaEmbed &&
this.vaAutoApply &&
this.vaFilterStatus === 'pending'
) {
this.applyPendingVaFilter()
}
}
/**
* Stop the bulk-validation flow (paste / autofill): invalidate in-flight
* responses via the epoch counter, reset spinner cells, hide the banner,
* and (when triggered from the banner's Cancel button) undo the change.
*/
public cancelBulkValidation(opts: { revert?: boolean } = {}) {
const wasActive = this.bulkValidation.active
// Invalidate any in-flight dynamicCellValidation responses. Note:
// sasService.request has no abort signal, so the network request itself
// keeps running — we only drop the response handling.
this.validationEpoch++
// Reset any cells still showing the loading spinner renderer.
const hot = this.hotInstance
if (hot && this.pendingSpinnerCells.size > 0) {
for (const key of this.pendingSpinnerCells) {
const [rStr, cStr] = key.split(',')
const r = Number(rStr)
const c = Number(cStr)
hot.setCellMeta(r, c, 'renderer', noSpinnerRenderer)
}
this.pendingSpinnerCells.clear()
}
// Drop placeholder entries (values still empty — request was cancelled).
this.cellValidationSource = this.cellValidationSource.filter(
(entry) => !entry.pending || entry.values.length > 0
)
this.bulkValidation = {
active: false,
done: 0,
total: 0
}
if (wasActive && opts.revert && hot) {
this.undoLastChange(hot)
}
if (hot) hot.render()
}
private undoLastChange(hot: Handsontable): void {
const plugin = hot.getPlugin('undoRedo') as unknown as
| { isUndoAvailable(): boolean; undo(): void }
| undefined
if (plugin?.isUndoAvailable()) plugin.undo()
}
/**
* Drive dynamic-source load + validation for cells that were bulk-filled
* (paste or autofill). HARDSELECT_HOOK / SOFTSELECT_HOOK columns need a SAS
* roundtrip; non-hook cells just need HOT's static validators. Caps backend
* concurrency, uses an epoch so a cancelled run can't mutate later state,
* and gates >3-cell runs behind a confirm modal.
*/
private async runBulkValidation(
hot: Handsontable,
ranges: Array<{
startRow: number
startCol: number
endRow: number
endCol: number
}>,
source: 'paste' | 'autofill'
): Promise<void> {
const rows = new Set<number>()
const hookTargets: Array<{ r: number; c: number }> = []
const hookCols = new Set<string>()
for (const range of ranges) {
for (let r = range.startRow; r <= range.endRow; r++) {
for (let c = range.startCol; c <= range.endCol; c++) {
rows.add(r)
const colKey = hot.colToProp(c) as string
if (this.dcValidator?.hasDqRules(colKey, ['HARDSELECT_HOOK'])) {
hookCols.add(colKey)
hookTargets.push({ r, c })
}
}
}
}
// No hook columns → HOT's own setDataAtCell → validateChanges pass
// (triggered by populateFromArray) already validates against the new
// value and paints htInvalid. A second validateRows here would race:
// it runs sync inside afterPaste/afterAutofill BEFORE applyChanges
// writes the data, captures the stale old value, and overwrites
// cellProperties.valid back to true — causing a 1-action lag.
if (hookTargets.length === 0) return
if (hookTargets.length === 1) {
const { r, c } = hookTargets[0]
await this.dynamicCellValidation(r, c)
hot.validateRows([...rows], () => hot.render())
return
}
if (hookTargets.length > 3) {
const colsList = [...hookCols].join(', ')
const ok = await this.showConfirmModal(
`Confirm ${source} validation`,
`You are about to trigger ${hookTargets.length} backend SAS request(s) for columns: ${colsList}. Do you wish to proceed?`
)
if (!ok) {
this.undoLastChange(hot)
return
}
}
const epoch = this.validationEpoch
this.bulkValidation = {
active: true,
done: 0,
total: hookTargets.length
}
const CONCURRENCY = 2
let idx = 0
await Promise.all(
Array.from({ length: CONCURRENCY }, async () => {
while (idx < hookTargets.length) {
if (epoch !== this.validationEpoch) return
const { r, c } = hookTargets[idx++]
await this.dynamicCellValidation(r, c, { skipRender: true })
if (epoch === this.validationEpoch) {
this.bulkValidation.done++
}
}
})
)
if (epoch !== this.validationEpoch) return
this.bulkValidation = {
...this.bulkValidation,
active: false
}
hot.validateRows([...rows], () => hot.render())
}
private showConfirmModal(title: string, message: string): Promise<boolean> {
this.confirmModal = { open: true, title, message }
return new Promise<boolean>((resolve) => {
this.confirmModalResolver = resolve
})
}
public onConfirmModalResult(value: boolean) {
const resolver = this.confirmModalResolver
this.confirmModalResolver = null
this.confirmModal = { ...this.confirmModal, open: false }
if (resolver) resolver(value)
}
timesClicked = 0
public hotClicked() {
if (this.timesClicked === 1 && this.hotTable.readOnly) {
this.editTable()
}
if (this.timesClicked === 0) {
this.timesClicked++
setTimeout(() => {
this.timesClicked = 0
}, 200)
}
}
public cleanExceed() {
this.exceedCells = []
}
public approversToggle() {
this.showApprovers = !this.showApprovers
}
public addRow() {
this.addingNewRow = true
setTimeout(() => {
const hot = this.hotInstance
const newIndex = this.dataSource.length
// hot.alter() (rather than splicing dataSource and calling
// updateSettings) is what triggers the formulas plugin's own
// insert-row hooks - without it, HyperFormula's sheet never learns
// about the new row and HARDFORMULA/SOFTFORMULA columns silently
// fall out of sync with the grid's own data.
hot.alter('insert_row_below', newIndex - 1, 1)
// alter() is expected to splice synchronously into dataSource, but
// this guards against relying on that if the data binding ever
// becomes async.
if (this.dataSource[newIndex]) {
this.dataSource[newIndex].noLinkOption = true
}
this.seedFormulaValuesForRow(newIndex)
this.updateEditStatusForRow(newIndex)
// Select the newly added row
hot.selectCell(newIndex, 0)
hot.render()
this.addingNewRow = false
this.reSetCellValidationValues()
})
}
/**
* Inserts a new row at the specified position and updates the table
*/
private insertRowAtPosition(targetRow: number): void {
const hot = this.hotInstance
// See addRow()'s comment - hot.alter() is required for HyperFormula to
// learn about the new row. beforeCreateRow only allows this while
// addingNewRow is set (see its own hook registration).
this.addingNewRow = true
hot.alter('insert_row_above', targetRow, 1)
this.addingNewRow = false
// alter() builds the new row from the grid's configured dataSchema
// (NOTNULL defaults included), which doesn't know about noLinkOption -
// set it directly on the row alter() just spliced into dataSource.
this.dataSource[targetRow].noLinkOption = true
this.seedFormulaValuesForRow(targetRow)
this.updateEditStatusForRow(targetRow)
this.reSetCellValidationValues()
}
/**
* Seeds HARDFORMULA/SOFTFORMULA columns on a newly-inserted row with
* their computed formula string, the same way applyFormulaRules seeds
* every row on initial load - a new row otherwise sits with a blank
* formula cell until the next full reload. DC.ORIG_VALUE naturally
* resolves to blank for this row (applyFormulaRules can't find a
* dataSourceUnchanged match for a row that never existed before).
*
* Written via hot.setDataAtRowProp (not a bare dataSource mutation, which
* is what applyFormulaRules itself does) so HyperFormula's engine
* actually learns about the new cell content - the same class of fix as
* hot.alter() above, just for a single cell instead of a row structure
* change.
*/
private seedFormulaValuesForRow(rowIndex: number): void {
const dqRules = this.dcValidator?.getDqDetails()
if (!dqRules || !hasFormulaRules(dqRules)) return
const hot = this.hotInstance
const userName = this.userService.user?.username ?? ''
const formulaRules = dqRules.filter(
(rule) =>
rule.RULE_TYPE === 'HARDFORMULA' || rule.RULE_TYPE === 'SOFTFORMULA'
)
// Computes only THIS row's formula string (parseFormulaRule directly,
// not applyFormulaRules on the full dataSource) - applyFormulaRules
// would overwrite every other row's formula column too, silently
// reverting any SOFTFORMULA override a user already typed elsewhere
// and rewriting every shifted row's cell-reference string (its
// spreadsheet position changed), both of which make classifyRow see a
// false diff against dataSourceUnchanged and misreport those untouched
// rows as modified.
for (const rule of formulaRules) {
const value = parseFormulaRule(rule.RULE_VALUE, {
columnNames: this.headerColumns,
rowIndex,
userName,
origValue: undefined
})
hot.setDataAtRowProp(rowIndex, rule.BASE_COL, value)
}
}
/**
* BASE_COL names of every HARDFORMULA/SOFTFORMULA rule whose result is
* expected to be *stable* for a given row (see getStableFormulaBaseCols
* for why DC.USER_NAME/DC.ORIG_VALUE/DC.ROW_STATUS-based rules are
* excluded). Used only by overlayFormulaRawValuesOnUnchanged and
* 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
* distinction.
*/
private getFormulaBaseCols(): string[] {
const dqRules = this.dcValidator?.getDqDetails()
if (!dqRules) return []
return getStableFormulaBaseCols(dqRules)
}
/**
* Overlays the true raw (pre-formula) value onto dataSourceUnchanged for
* every HARDFORMULA/SOFTFORMULA base col that already had real data (see
* markOverwrittenCells) - mutates in place. A HARDFORMULA/SOFTFORMULA
* rule can silently overwrite a value that already existed in the real
* dataset - that's a real change classifyRow's diff should pick up, not
* something that only becomes visible once an edit session starts. Shared
* by the initial load (so the row header agrees with EDIT_STATUS from the
* very first render) and editTable() (every time the baseline is rebuilt).
*/
private overlayFormulaRawValuesOnUnchanged(dataSourceUnchanged: any[]): void {
const formulaBaseCols = this.getFormulaBaseCols()
if (formulaBaseCols.length === 0 || !this.dataSourceRaw) return
dataSourceUnchanged.forEach((row, rowIndex) => {
const rawRow = this.dataSourceRaw[rowIndex]
if (!rawRow) return
for (const baseCol of formulaBaseCols) {
const rawValue = rawRow[baseCol]
if (rawValue === undefined || rawValue === null || rawValue === '')
continue
row[baseCol] = rawValue
}
})
}
/**
* Every column eligible to be checked/marked as "overwritten" - every
* revertable column (see getRevertableCols), not just the narrower
* formula-only set getFormulaBaseCols() returns. Used by
* markOverwrittenCells and the live afterChange sync - NOT by
* overlayFormulaRawValuesOnUnchanged/cancelEdit's preserve-on-cancel
* patch, which stay scoped to getFormulaBaseCols() specifically (see
* those methods' own doc comments for why).
*/
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.
*
* rowIndex is physical (matches dataSource's own order, same contract as
* syncOverwrittenComments()'s own dataSource.forEach caller below) -
* getDataAtRowProp and the comments plugin's own cell API all document a
* VISUAL row instead, so it's translated via hot.toVisualRow() for every
* Handsontable/plugin call, while dataSource itself stays indexed by the
* physical rowIndex throughout.
*/
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 visualRow = hot.toVisualRow(rowIndex)
if (visualRow === null) return
const colIndex = hot.propToCol(prop) as number
const commentsPlugin = hot.getPlugin('comments')
const currentValue = hot.getDataAtRowProp(visualRow, prop)
const hasCommentAlready = !!commentsPlugin.getCommentAtCell(
visualRow,
colIndex
)
const action = syncOverwrittenCellComment(
currentValue,
rawRow[prop],
hasCommentAlready
)
if (action === 'set') {
commentsPlugin.setCommentAtCell(
visualRow,
colIndex,
`${EditorComponent.ORIGINAL_VALUE_COMMENT_PREFIX}${rawRow[prop]}`
)
} else if (action === 'remove') {
commentsPlugin.removeCommentAtCell(visualRow, 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,
* permanent difference from the raw dataset (every future load
* recomputes the same formula again, and a direct edit made before this
* method ever ran isn't something afterChange would have seen), so
* nothing else would otherwise mark these rows modified. Doing this once
* here, before dataSourceUnchanged is ever cloned from dataSource, means
* the 'M' baseline is already shared by both snapshots - no special
* handling needed to keep it from being wiped out by a later
* cancelEdit().
*/
private markOverwrittenCells(): void {
this.syncOverwrittenComments()
const hot = this.hotInstance
const revertableCols = this.getRevertableColumnNames()
if (revertableCols.length === 0 || !this.dataSourceRaw) return
const currentRows = this.dataSource.map((row, rowIndex) => ({
...row,
...Object.fromEntries(
revertableCols.map((col) => [col, hot.getDataAtRowProp(rowIndex, col)])
)
}))
const changes = findOverwrittenCells(
currentRows,
this.dataSourceRaw,
revertableCols,
this.headerPks
)
const changedRows = new Set(changes.map((change) => change.rowIndex))
// Not classifyRow/updateEditStatusForRow - dataSourceUnchanged doesn't
// exist yet this early (only editTable() sets it), and these rows are
// already known-modified from the comment sync above; no need to
// reclassify.
//
// Deferred: setDataAtRowProp needs the Formulas plugin's hidden-column
// index mapping to have completed at least one settle pass. Called
// synchronously, right after the initial hot.updateSettings(), that
// mapping isn't ready yet and HyperFormula throws
// ExpectedValueOfTypeError (address.col resolves to undefined for the
// trimmed/hidden dc.row_status column) - since ngOnInit's getdata
// .catch() swallows anything thrown here, that leaves the whole table
// silently hidden. editTable() defers its own post-render work the
// same way, for the same index-mapping reason.
if (changedRows.size > 0) {
setTimeout(() => {
for (const rowIndex of changedRows) {
hot.setDataAtRowProp(
rowIndex,
EDIT_STATUS_COLUMN_NAME,
'M',
'editStatus'
)
}
}, 0)
}
}
/**
* 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,
* but written into a real Handsontable cell so DC.ROW_STATUS-based
* formulas can actually react to it. Written via hot.setDataAtRowProp
* with a distinct source string (not a bare dataSource mutation) so
* HyperFormula is notified and dependents recalculate - the source string
* also lets the afterChange hook below recognise and ignore its own
* writes, avoiding infinite recursion.
*
* rowIndex is physical (matches dataSource's own order, same contract as
* addRow()/insertRowAtPosition()'s own callers) - setDataAtRowProp
* documents a VISUAL row instead, so it's translated via
* hot.toVisualRow() right before that one call, while dataSource itself
* stays indexed by the physical rowIndex.
*/
private updateEditStatusForRow(rowIndex: number): void {
const dataRow = this.dataSource[rowIndex]
if (!dataRow) return
const status = classifyRow(
dataRow,
this.dataSourceUnchanged ?? this.dataSource,
this.headerPks
)
const visualRow = this.hotInstance.toVisualRow(rowIndex)
if (visualRow === null) return
this.hotInstance.setDataAtRowProp(
visualRow,
EDIT_STATUS_COLUMN_NAME,
status,
'editStatus'
)
}
public cancelSubmit() {
this.dataSource = this.helperService.deepClone(this.dataSourceBeforeSubmit)
this.dataSourceBeforeSubmit = []
this.hotTable.data = this.dataSource
const hot = this.hotInstance
this.updateSettingsSortSafe(
{
data: this.dataSource,
colHeaders: getDisplayColHeaders(
this.headerColumns,
this.cols,
this.useLabels
),
columns: this.cellValidation,
modifyColWidth: function (width: number, col: number) {
if (col === 0) {
return 60
}
if (width > 500) return 500
else return width
}
},
false
)
hot.selectCell(0, 0)
hot.render()
hot.validateRows(this.modifedRowsIndexes)
this.reSetCellValidationValues()
}
public getRowsSubmittingCount() {
if (this.sasService.getSasjsConfig().debug) {
this.loggerService.log(this.dataSource)
this.loggerService.log(this.dataSourceUnchanged)
}
let rowsUpdated = 0
let rowsDeleted = 0
let rowsAdded = 0
this.modifedRowsIndexes = []
this.dataModified = []
for (let i = 0; i < this.dataSource.length; i++) {
const dataRow = this.helperService.deepClone(this.dataSource[i])
switch (classifyRow(dataRow, this.dataSourceUnchanged, this.headerPks)) {
case 'D':
this.dataModified.push(dataRow)
rowsDeleted++
break
case 'A':
this.dataModified.push(dataRow)
this.modifedRowsIndexes.push(i)
rowsAdded++
break
case 'M':
this.dataModified.push(dataRow)
this.modifedRowsIndexes.push(i)
rowsUpdated++
break
case 'U':
break
}
}
this.rowsChanged = {
rowsUpdated,
rowsDeleted,
rowsAdded
}
}
private clearDuplicateValidation() {
const hot = this.hotInstance
// Clear previous duplicate validation styling
for (const rowIndex of this.duplicatePkIndexes) {
for (let col = 1; col <= this.readOnlyFields; col++) {
hot.removeCellMeta(rowIndex, col, 'valid')
hot.removeCellMeta(rowIndex, col, 'dupKey')
// Remove our custom class from cell metadata
// getCellMeta<M>() defaults to Record<string, unknown> in HOT 18; pin it to
// CellMeta so `.className` keeps its real string | string[] type below.
const cellMeta = hot.getCellMeta<Handsontable.CellMeta>(rowIndex, col)
if (cellMeta.className) {
let cleanedClassName: string
if (Array.isArray(cellMeta.className)) {
cleanedClassName = cellMeta.className
.filter((c) => c !== 'dc-invalid-cell')
.join(' ')
} else {
cleanedClassName = cellMeta.className
.replace('dc-invalid-cell', '')
.trim()
}
hot.setCellMeta(rowIndex, col, 'className', cleanedClassName)
}
}
}
this.duplicatePkIndexes = []
hot.render()
}
public validatePrimaryKeys() {
const hot = this.hotInstance
// Clear previous validation before applying new ones
this.clearDuplicateValidation()
// Get data from the data source instead of hot.getData() to ensure consistency
const myTable = this.dataSource
this.pkFields = []
for (let index = 0; index < myTable.length; index++) {
let pkRow = ''
for (let ind = 1; ind < this.readOnlyFields + 1; ind++) {
const colName = this.headerColumns[ind]
const value = myTable[index][colName] || ''
pkRow = pkRow + '|' + value
}
this.pkFields.push(pkRow)
}
const results: any = []
const rows = this.dataSource.length
// Only check for duplicates if we have data
if (this.pkFields.length > 0) {
for (let j = 0; j < this.pkFields.length; j++) {
for (let i = 0; i < this.pkFields.length; i++) {
if (
this.pkFields[j] === this.pkFields[i] &&
i !== j &&
this.pkFields[j] !== '|'
) {
results.push(i)
}
}
}
}
// Clear any existing validation marks for all cells
for (let row = 0; row < myTable.length; row++) {
for (let col = 0; col < this.headerColumns.length; col++) {
const cellMeta = hot.getCellMeta(row, col)
if (cellMeta) {
cellMeta.valid = true
cellMeta.dupKey = false
}
}
}
// Mark duplicate cells as invalid
for (let k = 0; k < results.length; k++) {
for (let index = 1; index < this.readOnlyFields + 1; index++) {
hot.setCellMeta(results[k], index, 'valid', false)
hot.setCellMeta(results[k], index, 'dupKey', true)
hot.setCellMeta(results[k], index, 'className', 'dc-invalid-cell')
}
}
this.duplicatePkIndexes = [...new Set(results.sort())]
hot.render()
}
/**
* After any change or update to the hot datasource we lose cell validation values.
* This function is called in those places, to update all cells with values if existing.
* Note that was discussed:
* Rows with same data does not have arrows until you click on them (arrows gets lost after addRow)
* That is because this function resets the values for the found hashes, and if multiple rows have same data,
* hash table contains only first row that is hashed, so others don't get arrow re-set
*
* @param specificRowForceValue re-set will apply force values only to this row. That is used in cases
* when we don't want to re-set force values of every row in the table
*/
public reSetCellValidationValues(
setForcedValues = false,
specificRowForceValue?: number
) {
const hot = this.hotInstance
for (const entry of this.cellValidationSource) {
const colSource = entry.values.map(
(el: DynamicCellValidation) => el.RAW_VALUE
)
hot.batch(() => {
const cellMeta = hot.getCellMeta(entry.row, entry.col)
const cellRule = this.dcValidator?.getRule(
(cellMeta.data as string) || ''
)
let cellSource: string[] | number[] | undefined
if (cellRule) {
cellSource = this.dcValidator?.getDqDropdownSource(cellRule)
}
if (!cellSource) cellSource = []
const combinedSource = [
...new Set([...cellSource, ...colSource])
] as string[]
this.currentEditRecordValidator?.updateRule(entry.col, {
source: combinedSource
})
hot.setCellMeta(entry.row, entry.col, 'source', combinedSource)
if (entry.values.length > 0) {
hot.setCellMeta(entry.row, entry.col, 'renderer', 'autocomplete')
hot.setCellMeta(entry.row, entry.col, 'editor', 'autocomplete.custom')
hot.setCellMeta(entry.row, entry.col, 'strict', entry.strict)
hot.setCellMeta(entry.row, entry.col, 'filter', false)
this.currentEditRecordValidator?.updateRule(entry.col, {
renderer: 'autocomplete',
editor: 'autocomplete.custom',
strict: entry.strict,
filter: false
})
}
this.reSetExtendedCellValidationValues(
entry,
undefined,
setForcedValues,
specificRowForceValue
)
hot.render()
})
}
}
public reSetExtendedCellValidationValues(
cellValidationEntry?: CellValidationSource,
row?: number,
setForcedValues = false,
specificRowForceValue?: number
) {
const hot = this.hotInstance
if (cellValidationEntry) {
if (!row) row = cellValidationEntry.row
const extendedValuesObject =
this.getExtendedValuesByCellValue(cellValidationEntry)
this.setExtendedValuesToCells(
cellValidationEntry,
row,
extendedValuesObject,
setForcedValues,
specificRowForceValue
)
return
}
for (const entry of this.cellValidationSource) {
const extendedValuesObject = this.getExtendedValuesByCellValue(entry)
this.setExtendedValuesToCells(
entry,
entry.row,
extendedValuesObject,
setForcedValues,
specificRowForceValue
)
}
}
private setExtendedValuesToCells(
cellValidationEntry: CellValidationSource,
row: number,
extendedValues: DynamicExtendedCellValidation[],
setForcedValues = false,
specificRowForceValue?: number
) {
const hot = this.hotInstance
const uniqueCells: any[] = []
for (const element of extendedValues) {
if (uniqueCells.indexOf(element.EXTRA_COL_NAME) < 0)
uniqueCells.push(element.EXTRA_COL_NAME)
}
for (const cell of uniqueCells) {
const valuesForCol = extendedValues.filter(
(x) => x.EXTRA_COL_NAME === cell
)
let colSource: any = valuesForCol.map(
(el: DynamicExtendedCellValidation) =>
el.DISPLAY_TYPE === 'C' ? el.RAW_VALUE_CHAR : el.RAW_VALUE_NUM
)
const cellCol = hot.propToCol(cell) as number
const dynamicValidationEl =
this.disabledBasicDynamicCellValidationMap.find(
(x) => x.row === row && x.col === cellCol
)
if (!dynamicValidationEl) {
this.disabledBasicDynamicCellValidationMap.push({
row,
col: cellCol,
active: false
})
}
hot.setCellMeta(row, cellCol, 'renderer', 'autocomplete')
hot.setCellMeta(row, cellCol, 'editor', 'autocomplete.custom')
hot.setCellMeta(row, cellCol, 'strict', cellValidationEntry.strict)
hot.setCellMeta(row, cellCol, 'filter', false)
this.currentEditRecordValidator?.updateRule(cellCol, {
renderer: 'autocomplete',
editor: 'autocomplete.custom',
strict: cellValidationEntry.strict,
filter: false
})
const cellMeta = hot.getCellMeta(row, cellCol)
const cellRule = this.dcValidator?.getRule(
(cellMeta.data as string) || ''
)
let cellSource: string[] | number[] | undefined
if (cellRule) {
cellSource = this.dcValidator?.getDqDropdownSource(cellRule)
}
if (!cellSource) cellSource = []
if (cellRule?.type === 'numeric') {
cellSource = this.helperService.convertArrayValues(
cellSource,
'number'
) as number[]
colSource = this.helperService.convertArrayValues(
colSource,
'number'
) as number[]
} else {
cellSource = this.helperService.convertArrayValues(
cellSource,
'string'
) as string[]
colSource = this.helperService.convertArrayValues(
colSource,
'string'
) as string[]
}
const combinedSource = [...new Set([...cellSource, ...colSource])]
hot.setCellMeta(row, cellCol, 'source', combinedSource)
this.currentEditRecordValidator?.updateRule(cellCol, {
source: combinedSource
})
if (setForcedValues) {
if (specificRowForceValue && specificRowForceValue !== row) {
return
}
const forceValue = valuesForCol.find(
(x: DynamicExtendedCellValidation) => x.FORCED_VALUE === 1
)
if (forceValue) {
// Adding timeout here makes forced values cell to re-validate itself
setTimeout(() => {
hot.setDataAtCell(
row,
cellCol,
forceValue.DISPLAY_TYPE === 'C'
? forceValue.RAW_VALUE_CHAR
: forceValue.RAW_VALUE_NUM,
'force_cell_validation_value'
)
if (this.currentEditRecordIndex === row) {
this.dataSource[this.currentEditRecordIndex][cell] =
forceValue.DISPLAY_TYPE === 'C'
? forceValue.RAW_VALUE_CHAR
: forceValue.RAW_VALUE_NUM
}
})
}
}
}
}
/**
* Parses values of extended cell validation for the given dynamic cell validation entry
* @param cellValidationEntry stored dynamic cell validation entry from which to parse extended validation values
* @param rowOverride if not provided, row that is used is row found in `cellValidationEntry`. This is needed when for example we change the w in hot
* we need to get `cellValue` from that row and not from hashed cell validation source.
* @returns extended values object
*/
private getExtendedValuesByCellValue(
cellValidationEntry: CellValidationSource,
rowOverride?: number
): DynamicExtendedCellValidation[] {
const hot = this.hotInstance
const cellValue = hot.getDataAtCell(
rowOverride ? rowOverride : cellValidationEntry.row,
cellValidationEntry.col
)
const valueIndex = (
cellValidationEntry.values.find((x) => x.RAW_VALUE === cellValue) || {}
).DISPLAY_INDEX
return (
cellValidationEntry.extended_values?.filter(
(x) => x.DISPLAY_INDEX === valueIndex
) || []
)
}
public checkSave() {
this.getRowsSubmittingCount()
if (
this.rowsChanged.rowsAdded === 0 &&
this.rowsChanged.rowsUpdated === 0 &&
this.rowsChanged.rowsDeleted === 0
) {
this.badEditTitle = 'No changes to submit'
this.badEditCause = 'Please modify some values and try again.'
this.badEdit = true
return
}
const hot = this.hotInstance
this.dataSourceBeforeSubmit = this.helperService.deepClone(this.dataSource)
// Clean up the data source by removing noLinkOption property
for (let i = 0; i < this.dataSource.length; i++) {
delete this.dataSource[i].noLinkOption
}
// Remove any completely empty rows from the end
while (this.dataSource.length > 0) {
const lastRow = this.dataSource[this.dataSource.length - 1]
const isEmpty = Object.keys(lastRow).every((key) => {
if (
key === '_____DELETE__THIS__RECORD_____' ||
key === EDIT_STATUS_COLUMN_NAME
)
return true
return !lastRow[key] || lastRow[key] === ''
})
if (isEmpty) {
this.dataSource.pop()
} else {
break
}
}
this.updateSettingsSortSafe(
{
data: this.dataSource,
colHeaders: getDisplayColHeaders(
this.headerColumns,
this.cols,
this.useLabels
),
columns: this.cellValidation,
modifyColWidth: function (width: number, col: number) {
if (width > 500) return 500
else return width
}
},
false
)
this.reSetCellValidationValues()
EditorComponent.cnt = 0
EditorComponent.nonPkCnt = 0
this.validatePrimaryKeys()
if (this.duplicatePkIndexes.length !== 0) {
this.pkDups = true
this.submit = false
this.cancelSubmit()
return
} else {
this.pkDups = false
}
hot.validateRows(this.modifedRowsIndexes, () => {
if (this.checkInvalid()) {
const abortMsg = 'Invalid Values are Present'
this.eventService.showInfoModal('Validation error', abortMsg)
return
}
this.submit = true
this.validationDone = 1
setTimeout(() => {
const txt: any = document.getElementById('formFields_8')
if (txt) txt.focus()
}, 200)
})
}
public async saveTable(data: any) {
const hot = this.hotInstance
// HARDFORMULA/SOFTFORMULA columns hold a live '=...' formula string in
// dataSource - HyperFormula recalculates the DISPLAYED value without
// ever mutating that underlying string, so submitting dataSource as-is
// would send the formula text itself instead of its computed result.
// getDataAtRowProp reads through the formulas plugin's own modifyData
// hook, which resolves the engine's live value for that cell. Must run
// over the full, unfiltered `data` (physical/array order, same
// reference as dataSource) - its index is what toVisualRow/
// getDataAtRowProp need; the filtered/mapped array below no longer
// lines up with actual grid rows.
const formulaRules = (this.dcValidator?.getDqDetails() ?? []).filter(
(rule) =>
rule.RULE_TYPE === 'HARDFORMULA' || rule.RULE_TYPE === 'SOFTFORMULA'
)
if (formulaRules.length > 0) {
data.forEach((row: any, physicalRowIndex: number) => {
const visualRowIndex = hot.toVisualRow(physicalRowIndex)
for (const rule of formulaRules) {
row[rule.BASE_COL] = hot.getDataAtRowProp(
visualRowIndex,
rule.BASE_COL
)
}
})
}
// Two things every other column (HARDFORMULA/SOFTFORMULA ones are
// already fully handled above) still needs before submission:
//
// 1. Strip the escape marker this app added (see
// escapeCharacterColumnValue/character-column-formula-plan.md) from
// every cell the app itself marked, so it never reaches the
// backend. A cell the user has since edited, or one whose leading
// `'` was already genuine backend data, is never in
// autoEscapedCells and so is never touched here - it submits
// exactly as it currently reads. autoEscapedCells is only ever
// populated for character columns, so this is naturally a no-op
// for any other column.
// 2. Resolve the computed value of a genuine, currently-live formula
// (typed, pasted, or "Apply as formula"'d - never auto-escaped, so
// its raw dataSource string still starts with a bare `=`) the same
// way the HARDFORMULA/SOFTFORMULA loop above does - otherwise the
// raw formula text itself would reach the backend instead of what
// the grid actually displays. Not scoped to character columns -
// HyperFormula evaluates a `=`-led value the same way regardless
// of the column's own declared type, and coerceNumericRow
// (beforePaste/beforeAutofill) deliberately leaves a non-numeric,
// formula-looking paste alone rather than forcing it to NaN, so a
// numeric column can end up holding a live formula too.
//
// Runs regardless of whether this table has any HARDFORMULA/
// SOFTFORMULA rules - formulas (and both of the above) are enabled
// everywhere.
const formulaBaseCols = getFormulaColumnNames(
this.dcValidator?.getDqDetails() ?? []
)
data.forEach((row: any, physicalRowIndex: number) => {
const visualRowIndex = hot.toVisualRow(physicalRowIndex)
const rowKey = getRowKey(row, this.headerPks)
for (const colName of this.headerColumns) {
if (formulaBaseCols.includes(colName)) continue
if (isAutoEscaped(this.autoEscapedCells, rowKey, colName)) {
row[colName] = unescapeFormula(row[colName])
} else if (
typeof row[colName] === 'string' &&
row[colName].startsWith('=')
) {
row[colName] = hot.getDataAtRowProp(visualRowIndex, colName)
}
}
})
data = data.filter((dataRow: any) => {
const elModified = this.dataModified.find((row) => {
for (const pkCol of this.headerPks) {
if (row[pkCol] !== dataRow[pkCol]) {
return false
}
}
return true
})
return !!elModified
})
data = data.map((row: any) => {
const deleteColValue = row['_____DELETE__THIS__RECORD_____']
delete row['_____DELETE__THIS__RECORD_____']
row['_____DELETE__THIS__RECORD_____'] = deleteColValue
// EDIT_STATUS is client-only (see editStatusColumnRule.ts) - it must
// never reach the backend.
delete row[EDIT_STATUS_COLUMN_NAME]
// If cell is numeric and value is dot `.` we change it to `null`
Object.keys(row).map((key: string) => {
const colRule = this.dcValidator?.getRule(key)
if (colRule?.type === 'numeric' && row[key] === '.') row[key] = null
})
return row
})
this.loggerService.log('Data submitted', data)
if (this.checkInvalid()) {
const abortMsg = 'Invalid Values are Present'
this.eventService.showInfoModal('Validation error', abortMsg)
this.cancelSubmit()
this.submit = false
return
}
this.validationDone = 0
this.saveLoading = true
if (
EditorComponent.cnt < 1 &&
this.duplicatePkIndexes.length === 0 &&
EditorComponent.nonPkCnt < 1
) {
this.saveLoading = true
this.disableSubmit = false
this.submit = true
const updateParams: any = {}
updateParams.ACTION = 'LOAD'
this.message = sanitiseForSas(this.message.replace(/\n/g, '. '))
updateParams.MESSAGE = this.message
// updateParams.APPROVER = this.approver;
updateParams.LIBDS = this.libds
if (this.cols) {
const submitData = data.slice(
0,
this.licenceState.value.submit_rows_limit
)
const success = await this.sasStoreService
.updateTable(
updateParams,
submitData,
'SASControlTable',
'editors/stagedata',
this.$dataFormats
)
.then((res: RequestWrapperResponse) => {
if (typeof res.adapterResponse.sasparams !== 'undefined') {
this.router.navigateByUrl(
'/stage/' + res.adapterResponse.sasparams[0].DSID
)
return true
}
let error = `Submit request failed`
if (res) {
const errorText =
typeof res === 'string' ? res : JSON.stringify(res)
error += `\n${errorText}`
}
this.eventService.showAbortModal(
'editors/stagedata',
error,
null,
'Submit error'
)
})
.catch((err: any) => {
console.log('err', err)
EditorComponent.cnt = 0
EditorComponent.nonPkCnt = 0
this.disableSubmit = true
this.submit = false
const errorText =
typeof err.adapterRespnse === 'string'
? err.adapterRespnse
: JSON.stringify(err.adapterRespnse)
this.eventService.showAbortModal(
'editors/stagedata',
`Submit request failed\n${errorText}`,
null,
'Submit error'
)
return false
})
if (success) return //stop code execution if route redirected
}
}
if (EditorComponent.cnt >= 1) {
this.pkNull = true
this.submit = true
} else {
this.submit = false
}
if (EditorComponent.nonPkCnt >= 1) {
this.noPkNull = true
this.submit = true
} else {
this.submit = false
}
this.cancelSubmit()
EditorComponent.cnt = 0
EditorComponent.nonPkCnt = 0
this.disableSubmit = true
}
public validatorRuleSource(colName: string) {
return this.dcValidator?.getRule(colName)
}
public checkInvalid() {
// Use Angular wrapper to access Handsontable element instead of DOM queries
if (!this.hotTableComponent || !this.hotTableComponent.hotInstance)
return false
const hotElement = this.hotTableComponent.hotInstance.rootElement
if (!hotElement) return false
// Check for standard Handsontable validation failures
const standardInvalidCells = hotElement.querySelectorAll('.htInvalid')
// Check for our custom duplicate primary key validation failures
const customInvalidCells = hotElement.querySelectorAll('.dc-invalid-cell')
return standardInvalidCells.length > 0 || customInvalidCells.length > 0
}
public goToEditor() {
this.router.navigateByUrl('/')
}
closeRecordEdit(confirmButtonClicked?: boolean) {
this.currentEditRecordIndex = -1
this.currentEditRecord = undefined
this.currentEditRecordValidator = undefined
if (this.recordAction === 'ADD' && !confirmButtonClicked) {
this.dataSource = this.helperService.deepClone(this.prevDataSource)
this.updateSettingsSortSafe(
{
data: this.dataSource
},
false
)
}
}
confirmRecordEdit(close = true) {
const closingRecordIndex = this.currentEditRecordIndex
if (close) this.currentEditRecordIndex = -1
this.columnHeader.map((colName: string) => {
const value = this.currentEditRecord[colName]
const isNum = this.$dataFormats?.vars[colName]?.type === 'num'
const specialMissing = isSpecialMissing(value)
if (isNum && !isNaN(value) && !specialMissing) {
this.currentEditRecord[colName] = value * 1
}
})
this.dataSource[closingRecordIndex] = this.currentEditRecord
this.hotTable.data[closingRecordIndex] = this.currentEditRecord
this.updateSettingsSortSafe(
{
data: this.dataSource
},
false
)
if (close) this.currentEditRecord = undefined
}
onNextRecord() {
this.confirmRecordEdit(false)
this.currentEditRecordIndex =
this.currentEditRecordIndex >= this.dataSource.length - 1
? 0
: this.currentEditRecordIndex + 1
this.editRecord(null, this.currentEditRecordIndex)
}
onPreviousRecord() {
this.confirmRecordEdit(false)
this.currentEditRecordIndex =
this.currentEditRecordIndex <= 0
? this.dataSource.length - 1
: this.currentEditRecordIndex - 1
this.editRecord(null, this.currentEditRecordIndex)
}
addRecordButtonClick() {
if (this.restrictions.restrictAddRecord) {
this.eventService.showDemoLimitModal('Add Record')
return
}
this.addEditNewRecord()
}
addEditNewRecord() {
this.addRecord()
setTimeout(() => {
this.editRecord(null, this.dataSource.length - 1, true)
}, 1000)
}
addRecord() {
this.addRow()
}
editRecord(item: Element | null, index?: number, newRecord?: boolean) {
if (this.restrictions.restrictEditRecord) {
this.eventService.showDemoLimitModal('Edit Record')
return
}
if (index === undefined || index < 0) return
if (this.restrictions.restrictEditRecord) {
return
}
this.recordAction = newRecord ? 'ADD' : 'EDIT'
if (this.hotTable.readOnly) {
this.editTable(false, newRecord)
}
// Create copy of DC validator to be used in RECORD MODAL
this.currentEditRecordValidator = this.helperService.deepClone(
this.dcValidator
)
if (newRecord) {
this.prevDataSource = this.helperService.deepClone(this.dataSource)
this.prevDataSource.pop()
} else {
const currentEditRecordCellsMeta = this.helperService.deepClone(
this.hotInstance.getCellMetaAtRow(
index
) as Partial<Handsontable.CellProperties>[]
)
// Update that copy with current cells meta (dynamic validation data)
for (const cellMeta of currentEditRecordCellsMeta) {
if (cellMeta) {
const data = cellMeta.prop?.toString() //------------
delete cellMeta.prop // We convert to be able to update dcValidator rule by using CellProperties
delete cellMeta.data //-----------------
this.currentEditRecordValidator?.updateRule(cellMeta.col!, {
...cellMeta,
data: data
})
}
}
}
this.currentEditRecordIndex = index
this.currentEditRecord = this.helperService.deepClone(
this.dataSource[index]
)
}
toggleHotPlugin(pluginName: string, enable: boolean) {
const hot = this.hotInstance
const contextMenuPlugin = hot.getPlugin<any>(pluginName)
if (!contextMenuPlugin) {
console.warn(
'Toggle Hot Plugin failed - Plugin named: ' +
pluginName +
' - could not be found.'
)
return
}
setTimeout(() => {
// The instance may be destroyed/rebuilt within this 100ms window (e.g. a
// VA filter reload repopulates the grid). A destroyed plugin has its `hot`
// reference deleted, so enablePlugin()/disablePlugin() would throw on
// `this.hot.getSettings()`. Bail if the instance is gone or was swapped.
if (
hot.isDestroyed ||
this.hotInstance !== hot ||
!contextMenuPlugin.hot
) {
return
}
if (enable) {
contextMenuPlugin.enablePlugin()
} else {
contextMenuPlugin.disablePlugin()
}
hot.render()
}, 100)
}
private dynamicCellValidationDisabled(row: number, col: number) {
const rowColFound = this.disabledBasicDynamicCellValidationMap.find(
(x) => x.row === row && x.col === col && !x.active
)
return !!rowColFound
}
/**
* This function takes row and column numbers for the cel to be validated and pouplated with values.
* It will send the row values without the current column to the sas.
* Sas will return values and if length greater then zero cel becomes dropdown type and values from sas
* put to source.
* @param row handsontable row
* @param column handsontable column
*/
public async dynamicCellValidation(
row: number,
column: number,
opts?: { skipRender?: boolean },
retried = false
): Promise<void> {
if (this.dynamicCellValidationDisabled(row, column))
return Promise.resolve()
const hot = this.hotInstance
const cellMeta = hot.getCellMeta(row, column)
if (cellMeta.readOnly) return Promise.resolve()
const cellData = hot.getDataAtCell(row, column)
const clickedRow = this.helperService.deepClone(this.dataSource[row])
// hot.colToProp resolves the column's actual configured data key -
// Object.keys(clickedRow)[column] only worked by coincidence for a
// fully-populated row; a sparse row (one whose backend source never
// populated some earlier column) has fewer real keys than the grid has
// columns, so counting into its own key list drifts out of alignment
// (or runs out of bounds entirely) once column indexes past the gap.
const clickedColumnKey = hot.colToProp(column) as string
const skipRender = !!opts?.skipRender
const myEpoch = this.validationEpoch
/**
* We will hash the row (without current column) so later we check if hash is the same
* we set the values relative to that hash
* if not we fire the request.
*/
const hashedRow = this.helperService.deleteKeysAndHash(
clickedRow,
[clickedColumnKey, 'noLinkOption'],
false
)
const validationSourceIndex = this.cellValidationSource.findIndex(
(entry: CellValidationSource) => entry.hash === hashedRow
)
/**
* Set the values for found hash.
*/
if (validationSourceIndex > -1) {
// In-flight dedup: another call with the same hash is mid-request.
// Wait for it then re-enter once so we walk the populated cache-hit
// path instead of validating against the empty placeholder.
const inFlight = this.cellValidationSource[validationSourceIndex].pending
if (inFlight && !retried) {
try {
await inFlight
} catch {
/* swallowed — original caller handles */
}
if (myEpoch !== this.validationEpoch) return
return this.dynamicCellValidation(row, column, opts, true)
}
}
if (validationSourceIndex > -1) {
let colSource = this.cellValidationSource[
validationSourceIndex
].values.map((el) => el.RAW_VALUE)
// `.source` (dropdown/autocomplete list) is typed unknown[] | Function here since
// getCellMeta() isn't given a type param; cast to the array branch we actually use.
const cellHadSource =
((hot.getCellMeta(row, column).source as unknown[]) || []).length < 1
const cellHasValue = cellData !== ' '
hot.batch(() => {
const cellMeta = hot.getCellMeta(row, column)
const cellRule = this.dcValidator?.getRule(
(cellMeta.data as string) || ''
)
let cellSource: string[] | number[] | undefined
if (cellRule) {
cellSource = this.dcValidator?.getDqDropdownSource(cellRule)
}
if (!cellSource) cellSource = []
if (cellRule?.type === 'numeric') {
cellSource = this.helperService.convertArrayValues(
cellSource,
'number'
) as number[]
colSource = this.helperService.convertArrayValues(
colSource,
'number'
) as number[]
} else {
cellSource = this.helperService.convertArrayValues(
cellSource,
'string'
) as string[]
colSource = this.helperService.convertArrayValues(
colSource,
'string'
) as string[]
}
const cellSourceCombined = [
...new Set([...cellSource, ...colSource])
] as string[]
hot.setCellMeta(row, column, 'source', cellSourceCombined)
this.currentEditRecordValidator?.updateRule(column, {
source: cellSourceCombined
})
if (
this.cellValidationSource[validationSourceIndex].values.length > 0
) {
const strict = this.cellValidationSource[validationSourceIndex].strict
hot.setCellMeta(row, column, 'renderer', 'autocomplete')
hot.setCellMeta(row, column, 'editor', 'autocomplete.custom')
hot.setCellMeta(row, column, 'strict', strict)
hot.setCellMeta(row, column, 'filter', false)
this.currentEditRecordValidator?.updateRule(column, {
renderer: 'autocomplete',
editor: 'autocomplete.custom',
strict: strict,
filter: false
})
}
this.reSetExtendedCellValidationValues(
this.cellValidationSource[validationSourceIndex],
row,
cellHadSource && cellHasValue
)
if (!skipRender) hot.render()
})
} else if (validationSourceIndex < 0) {
/**
* Send request to sas.
*/
const data = {
SASControlTable: [
{
libds: this.libds,
variable_nm: clickedColumnKey
}
],
// EDIT_STATUS is client-only (see editStatusColumnRule.ts) - it
// must never reach the backend.
source_row: [withoutEditStatus(clickedRow)]
}
const validationHook = this.dcValidator
?.getDqDetails(clickedColumnKey)
.find(
(rule: DQRule) =>
rule.RULE_TYPE === 'SOFTSELECT_HOOK' ||
rule.RULE_TYPE === 'HARDSELECT_HOOK'
)
/**
* Do the validation only if current column has validation hooks in place.
*/
if (validationHook) {
this.cellValidationSource.push({
row: row,
col: column,
strict: validationHook.RULE_TYPE === 'HARDSELECT_HOOK',
values: [],
hash: hashedRow,
count: this.cellValidationSource.length + 1
})
this.currentEditRecordLoadings.push(column)
const spinnerKey = `${row},${column}`
// Defer the spinner renderer so the click event finishes settling
// HOT's focus catcher before we replace td.innerHTML. Without this
// defer, clicking the cell loses focus immediately.
// Skip the spinner entirely if SAS responds before this fires —
// avoids the brief flicker users were seeing on fast responses.
// Also skip during paste (skipRender) — the progress banner handles
// visual feedback and cell spinners would just flicker.
const spinnerTimeout: ReturnType<typeof setTimeout> | null = skipRender
? null
: setTimeout(() => {
hot.setCellMeta(row, column, 'renderer', spinnerRenderer)
this.pendingSpinnerCells.add(spinnerKey)
hot.render()
}, 150)
const pendingPromise = this.sasService
.request('editors/getdynamiccolvals', data, undefined, {
suppressSuccessAbortModal: true,
suppressErrorAbortModal: true
})
.then(async (res: RequestWrapperResponse) => {
if (spinnerTimeout) clearTimeout(spinnerTimeout)
this.pendingSpinnerCells.delete(spinnerKey)
// Cancelled mid-flight — drop the placeholder entry so future
// calls don't see an empty cache hit, and skip all UI work.
if (myEpoch !== this.validationEpoch) {
const idx = this.cellValidationSource.findIndex(
(e) => e.hash === hashedRow
)
if (idx > -1) this.cellValidationSource.splice(idx, 1)
return
}
const colSource = res.adapterResponse.dynamic_values.map(
(el: DynamicCellValidation) => el.RAW_VALUE
)
this.currentEditRecordLoadings.splice(
this.currentEditRecordLoadings.indexOf(column),
1
)
if (colSource.length > 0) {
const validationSourceIndex = this.cellValidationSource.findIndex(
(entry: CellValidationSource) => entry.hash === hashedRow
)
if (validationSourceIndex > -1) {
this.cellValidationSource[validationSourceIndex] = {
...this.cellValidationSource[validationSourceIndex],
row: row,
col: column,
values: res.adapterResponse.dynamic_values,
extended_values: res.adapterResponse.dynamic_extended_values,
pending: undefined
}
}
/**
* In the case that the original value is not included in the newly created cell dropdown
* and validation type is HARDSELECT, the cell shoud be red
*/
await new Promise<void>((resolve) =>
setTimeout(() => {
this.reSetCellValidationValues(true, row)
if (!skipRender) {
hot.render()
hot.validateRows([row])
}
resolve()
}, 100)
)
} else {
if (!skipRender) {
hot.setCellMeta(row, column, 'renderer', noSpinnerRenderer)
hot.render()
}
const idx = this.cellValidationSource.findIndex(
(e) => e.hash === hashedRow
)
if (idx > -1) this.cellValidationSource[idx].pending = undefined
}
/**
* If hash table limit reached, remove the oldest element.
* Oldest element is element with lowest `count` number.
*/
if (this.cellValidationSource.length > this.validationTableLimit) {
const oldestElement = this.cellValidationSource.reduce(
(prev, curr) => (prev.count < curr.count ? prev : curr)
)
const oldestElementIndex =
this.cellValidationSource.indexOf(oldestElement)
this.cellValidationSource.splice(oldestElementIndex, 1)
}
})
.catch((err: any) => {
if (spinnerTimeout) clearTimeout(spinnerTimeout)
this.pendingSpinnerCells.delete(spinnerKey)
const currentRowHashIndex = this.cellValidationSource.findIndex(
(x) => x.hash === hashedRow
)
this.cellValidationSource.splice(currentRowHashIndex, 1)
if (myEpoch !== this.validationEpoch) return
if (!skipRender) {
hot.batch(() => {
// Render error icon inside a cell
hot.setCellMeta(row, column, 'renderer', errorRenderer)
hot.render()
})
}
//Stop edit record modal loading spinner
this.currentEditRecordLoadings.splice(
this.currentEditRecordLoadings.indexOf(column),
1
)
//Show error on edit record modal
this.currentEditRecordErrors.push(column)
// After waiting time remove the error icon from cell and edit record modal field
setTimeout(() => {
if (!skipRender) {
hot.setCellMeta(row, column, 'renderer', noSpinnerRenderer)
hot.render()
}
//Remove error icon on the edit record modal field
this.currentEditRecordErrors.splice(
this.currentEditRecordErrors.indexOf(column),
1
)
}, 3000)
this.reSetCellValidationValues()
this.loggerService.log('getdynamiccolvals error:', err)
})
const entryIdx = this.cellValidationSource.findIndex(
(e) => e.hash === hashedRow
)
if (entryIdx > -1) {
this.cellValidationSource[entryIdx].pending = pendingPromise
}
return pendingPromise
}
}
return Promise.resolve()
}
checkEmptyRowWhenFilter() {
this.zeroFilterRows = false
if (
typeof this.filter_pk !== 'undefined' &&
this.hotTable.data.length === 1
) {
if ([null, ''].includes(this.hotTable.data[0][this.headerPks[0]]))
this.zeroFilterRows = true
}
}
onRecordInputFocus(event: EditRecordInputFocusedEvent) {
this.dynamicCellValidation(this.currentEditRecordIndex, event.colName)
}
executeDynamicCellValidationIfApplicable(colProp: any, col: any, row: any) {
const hashedRow = this.helperService.deleteKeysAndHash(
this.dataSource[row],
[colProp, 'noLinkOption']
)
const cellValidation = this.cellValidationSource.find(
(entry: CellValidationSource) =>
entry.hash === hashedRow && col === entry.col
)
if (
cellValidation &&
cellValidation.extended_values &&
cellValidation.extended_values.length > 0
) {
const extendedValidationObject = this.getExtendedValuesByCellValue(
cellValidation,
row
)
this.setExtendedValuesToCells(
cellValidation,
row,
extendedValidationObject,
true
)
}
}
datasetInfoModalRowClicked(value: Version | DSMeta) {
if ((<Version>value).LOAD_REF !== undefined) {
// Type is Version
const row = value as Version
const url = `/stage/${row.LOAD_REF}`
this.router.navigate([url])
}
}
viewboxManager() {
this.viewboxes = true
}
get totalRowsChanged() {
return (
this.rowsChanged.rowsUpdated +
this.rowsChanged.rowsDeleted +
this.rowsChanged.rowsAdded
)
}
/**
* Function checks if selected hot cell is solo cell selected
* and if it is, set the `filter` property based on filter param.
*
* @param filter
*/
private setCellFilter(filter: boolean) {
const hotSelected = this.hotInstance.getSelected()
if (!hotSelected) return
const selection = hotSelected ? hotSelected[0] : hotSelected
// When we open a dropdown we want filter disabled so value in cell
// don't filter out items, since we want to see them all.
// But when we start typing we want to be able to start filtering values
// again
if (selection) {
const startRow = selection[0]
const endRow = selection[2]
const startCell = selection[1]
const endCell = selection[3]
if (startRow === endRow && startCell === endCell) {
const cellMeta = this.hotInstance.getCellMeta(startRow, startCell)
// If filter is not already set at the value in the param, set it
if (cellMeta && cellMeta.filter === !filter) {
this.hotInstance.setCellMeta(startRow, startCell, 'filter', filter)
}
}
}
}
async ngOnInit() {
// VA data-driven content: register the postMessage listener as early as
// possible (like the SAS sample's load-time registration) so VA's initial
// data message isn't missed during the editor's getdata load. The message
// is buffered on the service and replayed once the grid is ready.
if (this.isVaEmbed) this.subscribeToVaMessages()
// Initialize hot table settings
this.updateHotTableSettings()
this.licenceService.hot_license_key.subscribe(
(hot_license_key: string | undefined) => {
this.hotTable.licenseKey = hot_license_key
this.updateHotTableSettings() // Update settings when license key changes
}
)
// URL is the source of truth for the labels toggle: react to `?labels=`
// changes (including same-page navigation from the dropdown menu item)
// rather than reading it once from the route snapshot.
this._queryParams = this.route.queryParams.subscribe((params) => {
this.useLabels = parseLabelsParam(new URLSearchParams(params).toString())
this.applyDisplayColHeaders()
})
this._query = this.sasStoreService.query.subscribe((query: any) => {
if (query.libds === this.libds) {
this.whereString = query.string
this.clauses = query.obj
// this.libds = query.libds
}
})
// recover lib and table parameters from url; filter pk is optional - if filter is applied
const myParams: any = {}
if (typeof this.route.snapshot.params['libMem'] !== 'undefined') {
this.libds = this.route.snapshot.params['libMem']
this.filter_pk = this.route.snapshot.params['filterId']
if (this.route.snapshot.url[0].path === 'edit-record') {
if (typeof this.filter_pk !== 'undefined') {
this.recordAction = 'EDIT'
} else {
this.recordAction = 'ADD'
}
}
myParams.LIBDS = this.libds
if (typeof this.filter_pk !== 'undefined') {
myParams.FILTER_RK = parseInt(this.filter_pk)
}
myParams.OUTDEST = 'WEB'
if (this.libds) {
globals.editor.library = this.libds.split('.')[0]
globals.editor.table = this.libds.split('.')[1]
}
}
// VA initial load: if VA's first message already carries a filter, apply it
// BEFORE the first getdata so the initial fetch is filtered (no full-table
// load + reload). Navigates away on success; this instance aborts its load.
// Falls back to the normal unfiltered load on timeout/no-filter/failure.
if (await this.maybeDeferInitialVaLoad()) return
if (this.libds) {
this.getdataError = false
await this.sasStoreService
.callService(myParams, 'SASControlTable', 'editors/getdata', this.libds)
.then((res: EditorsGetDataServiceResponse) => {
this.initSetup(res)
})
.catch((err: any) => {
// A synchronous throw inside initSetup (called from the .then()
// above) lands here too, not just a network failure - log it, or
// the table just renders hidden with no clue why.
// eslint-disable-next-line no-console
console.error('editors/getdata failed:', err)
this.getdataError = true
this.tableTrue = true
})
}
}
/**
* VA mode, initial (unfiltered) route only: briefly wait for VA's buffered
* first message and, if it already carries a filter, save that filter and
* navigate to /editor/<table>/<FILTER_RK> so the very first getdata is
* filtered. Returns true if it navigated (caller must abort its own load),
* false to proceed with the normal unfiltered load.
*
* The filter is built directly from VA parameters (param label === DC column,
* which VA↔DC matching already requires; numeric inferred from the VA param
* dataType/value) because DC column metadata isn't available until getdata
* has run. Any failure falls back to the normal load; the post-load replay
* (with full metadata) reconciles to the same filter signature.
*/
private async maybeDeferInitialVaLoad(): Promise<boolean> {
if (!this.isVaEmbed) return false
// Only the initial, unfiltered route — never when a FILTER_RK is present.
if (typeof this.route.snapshot.params['filterId'] !== 'undefined')
return false
const libds = this.route.snapshot.params['libMem']
if (!libds) return false
// VA posts its first message before Angular boots, so it's already in the
// external buffer by now — read it directly (no polling). If it hasn't
// arrived yet, skip the optimisation and let the normal load + live message
// apply the filter.
const msg = this.vaMessaging.latestMessage()
if (!msg) return false
const clauses = this.vaFilter.buildInitialClauses(msg)
if (clauses.length === 0) return false
try {
const res: any = await this.sasStoreService.saveQuery(libds, clauses)
const id = res?.result?.[0]?.FILTER_RK
const table = res?.result?.[0]?.FILTER_TABLE
if (id === undefined || table === undefined) return false
// Seed the cross-reload signature so the post-reload replay is a no-op.
this.vaMessaging.filterSignature = this.vaFilter.signature(clauses)
await this.reloadEditorRoute('/editor/' + table + '/' + id)
return true
} catch {
return false
}
}
ngAfterViewChecked() {
this.scheduleFilterOverflowCheck()
}
/**
* Measures whether the applied-filter clause fits the collapsed single line,
* and shows or hides the chevron accordingly. The measurement is deferred
* because `filterOverflows` and `filterExpanded` are bound in this view -
* setting them inside the change-detection cycle would raise
* ExpressionChangedAfterItHasBeenChecked.
*/
private scheduleFilterOverflowCheck() {
const el = this.infoBar?.nativeElement
if (!el) {
return
}
const text = el.querySelector('.infoBar-text') as HTMLElement | null
if (!text) {
return
}
// re-measure only when the clause, the available width, or the state changes
const key = `${this.queryText}|${text.clientWidth}|${this.filterExpanded}`
if (key === this.filterMeasuredKey) {
return
}
this.filterMeasuredKey = key
setTimeout(() => {
// while expanded the clause wraps, so there is nothing to measure
if (this.filterExpanded) {
return
}
this.filterOverflows = text.scrollWidth > text.clientWidth
})
}
public toggleFilterPanel() {
this.filterExpanded = !this.filterExpanded
}
ngAfterViewInit() {
// Fix ARIA accessibility issues after table initialization
setTimeout(() => {
this.fixAriaAccessibility()
}, 1000)
// Set up event listener for hot table element
// Double click to edit
setTimeout(() => {
if (this.hotTableComponent && this.hotTableComponent.hotInstance) {
const hotElement = this.hotTableComponent.hotInstance.rootElement
if (hotElement) {
hotElement.addEventListener('mousedown', (event: MouseEvent) => {
if (!this.uploadPreview) {
this.hotClicked()
}
setTimeout(() => {
const menuDebugItem: any =
document.querySelector('.debug-switch-item') || undefined
if (menuDebugItem) menuDebugItem.click()
}, 100)
})
}
}
}, 100)
}
/**
* Recomputes `colHeaders` from `headerColumns`/`cols` for the current
* `useLabels` state, and pushes it into a live grid via `updateSettings`
* if one is mounted (no refetch needed).
*/
private applyDisplayColHeaders() {
if (!this.hotInstance || !this.headerColumns.length) return
const colHeaders = getDisplayColHeaders(
this.headerColumns,
this.cols,
this.useLabels
)
this.updateSettingsSortSafe({ colHeaders }, false)
}
ngOnDestroy() {
this._queryParams?.unsubscribe()
// Clean up the MutationObserver
if (this.ariaObserver) {
this.ariaObserver.disconnect()
this.ariaObserver = undefined
}
// Clean up the interval
if (this.ariaCheckInterval) {
clearInterval(this.ariaCheckInterval)
this.ariaCheckInterval = undefined
}
// Stop observing the grid container for resizes
if (this.gridResizeObserver) {
this.gridResizeObserver.disconnect()
this.gridResizeObserver = undefined
}
// Remove the native paste listener (see its own registration comment -
// attached once on rootElement rather than per edit session)
if (this.pasteListener && this.hotInstance?.rootElement) {
this.hotInstance.rootElement.removeEventListener(
'paste',
this.pasteListener
)
this.pasteListener = undefined
}
// Cancel any pending debounced VA apply
if (this.vaDebounceTimer) {
clearTimeout(this.vaDebounceTimer)
this.vaDebounceTimer = undefined
}
// Remove the VA postMessage listener
if (this.vaUnsubscribe) {
this.vaUnsubscribe()
this.vaUnsubscribe = undefined
}
}
/**
* Re-run height+render when the grid container settles.
*/
private observeGridResize() {
const el = this.hotInstance?.rootElement?.parentElement
if (!el) return
this.gridResizeObserver?.disconnect()
this.gridResizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
const hot = this.hotInstance
if (!hot || hot.isDestroyed) return
this.updateSettingsSortSafe({ height: this.hotTable.height }, false)
hot.render()
})
})
this.gridResizeObserver.observe(el)
}
/**
* Fixes ARIA accessibility issues in the Handsontable component
* This addresses the accessibility report issues with treegrid and presentation roles
*/
private fixAriaAccessibility() {
// Use a more aggressive approach to find and fix all ARIA issues
const fixAriaIssues = () => {
// Specifically target Handsontable wrapper elements that are causing issues
const hotWrappers = document.querySelectorAll(
'.ht-wrapper, .wtHolder, [id^="ht_"]'
)
hotWrappers.forEach((wrapper) => {
// Remove problematic ARIA attributes from Handsontable wrappers
wrapper.removeAttribute('role')
wrapper.removeAttribute('aria-rowcount')
wrapper.removeAttribute('aria-colcount')
wrapper.removeAttribute('aria-multiselectable')
})
// Find all elements with problematic ARIA roles in the entire document
const allTreegridElements = document.querySelectorAll('[role="treegrid"]')
const allPresentationElements = document.querySelectorAll(
'[role="presentation"]'
)
// Fix treegrid role issues - remove them completely as they're causing problems
allTreegridElements.forEach((element) => {
element.removeAttribute('role')
element.removeAttribute('aria-rowcount')
element.removeAttribute('aria-colcount')
element.removeAttribute('aria-multiselectable')
})
// Fix presentation role issues - remove them if they contain interactive elements
allPresentationElements.forEach((element) => {
const hasInteractiveChildren =
element.querySelectorAll(
'button, input, select, textarea, [tabindex], [onclick], [contenteditable]'
).length > 0
if (hasInteractiveChildren) {
element.removeAttribute('role')
}
})
// Also fix any elements with aria-rowcount="-1" which is problematic
const negativeRowCountElements = document.querySelectorAll(
'[aria-rowcount="-1"]'
)
negativeRowCountElements.forEach((element) => {
element.removeAttribute('aria-rowcount')
})
// Ensure proper table structure
const tableElements = document.querySelectorAll('table')
tableElements.forEach((table) => {
if (!table.getAttribute('role')) {
table.setAttribute('role', 'table')
}
// Ensure table headers have proper scope
const headerCells = table.querySelectorAll('th')
headerCells.forEach((th) => {
if (!th.getAttribute('scope')) {
th.setAttribute('scope', 'col')
}
})
})
// Add proper ARIA labels to interactive elements
const interactiveElements = document.querySelectorAll(
'button, input, select, textarea, [contenteditable]'
)
interactiveElements.forEach((element) => {
if (
!element.getAttribute('aria-label') &&
!element.getAttribute('aria-labelledby')
) {
const textContent = element.textContent?.trim()
if (textContent) {
element.setAttribute('aria-label', textContent)
}
}
})
}
// Run the fix immediately
fixAriaIssues()
// Run it again after a short delay to catch any dynamically created elements
setTimeout(fixAriaIssues, 100)
setTimeout(fixAriaIssues, 500)
setTimeout(fixAriaIssues, 1000)
setTimeout(fixAriaIssues, 2000)
// Set up a periodic check to ensure accessibility fixes are maintained
if (!this.ariaCheckInterval) {
this.ariaCheckInterval = setInterval(fixAriaIssues, 3000)
}
// Set up a MutationObserver to continuously monitor for new problematic elements
if (!this.ariaObserver) {
this.ariaObserver = new MutationObserver((mutations) => {
let shouldFix = false
mutations.forEach((mutation) => {
if (
mutation.type === 'attributes' &&
(mutation.attributeName === 'role' ||
mutation.attributeName === 'aria-rowcount')
) {
shouldFix = true
}
if (mutation.type === 'childList') {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as Element
if (
element.hasAttribute('role') ||
element.hasAttribute('aria-rowcount')
) {
shouldFix = true
}
}
})
}
})
if (shouldFix) {
setTimeout(fixAriaIssues, 50)
}
})
// Start observing the entire document for changes
this.ariaObserver.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: [
'role',
'aria-rowcount',
'aria-colcount',
'aria-multiselectable'
]
})
}
}
initSetup(response: EditorsGetDataServiceResponse, attempt = 0) {
if (this.getdataError) return
if (!response || !response.data) return
this.hotInstance = this.hotTableComponent?.hotInstance!
if (!this.hotInstance) {
// Retry init, don't permanently abort
if (attempt < 60) {
setTimeout(() => this.initSetup(response, attempt + 1), 50)
}
return
}
this.cols = response.data.cols
this.dsmeta = response.data.dsmeta
this.versions = response.data.versions || []
const notes = this.dsmeta.find((item) => item.NAME === 'NOTES')
const longDesc = this.dsmeta.find((item) => item.NAME === 'DD_LONGDESC')
const shortDesc = this.dsmeta.find((item) => item.NAME === 'DD_SHORTDESC')
if (notes && notes.VALUE) {
this.dsNote = notes.VALUE
} else if (longDesc && longDesc.VALUE) {
this.dsNote = longDesc.VALUE
} else if (shortDesc && shortDesc.VALUE) {
this.dsNote = shortDesc.VALUE
} else {
this.dsNote = ''
}
const hot = this.hotInstance
const approvers: Approver[] = response.data.approvers
if (this.cols) {
this.headerArray = parseTableColumns(this.cols)
}
// Note: the above this.headerArray is being reassigned without being used.
// So, above assignment does not make sense.
approvers.forEach((item: Approver) => {
this.approvers.push(item.PERSONNAME)
})
this.tableTrue = true
this.libds = response.libds
this.hotTable.data = response.data.sasdata
this.headerColumns = response.data.sasparams[0].COLHEADERS.split(',')
this.headerPks = response.data.sasparams[0].PK.split(' ')
this.columnLevelSecurityFlag = !!response.data.sasparams[0].CLS_FLAG
if (this.columnLevelSecurityFlag)
this.setRestrictions({
restrictAddRow: true,
removeEditRecordButton: true,
removeAddRecordButton: true
})
this.checkEmptyRowWhenFilter()
if (this.headerColumns.indexOf('_____DELETE__THIS__RECORD_____') !== -1) {
this.headerColumns[
this.headerColumns.indexOf('_____DELETE__THIS__RECORD_____')
] = 'Delete?'
}
this.headerArray = this.headerColumns.slice(1)
// EDIT_STATUS is never part of COLHEADERS (it's client-synthesized, see
// editStatusColumnRule.ts) - appended after headerArray is derived so it
// stays out of whatever headerArray drives, but before headerColumns is
// used below for cell-reference math (applyFormulaRules/DcValidator's
// rules must stay index-aligned with headerColumns).
this.headerColumns.push(EDIT_STATUS_COLUMN_NAME)
if (response.data.sasparams[0].DTVARS !== '') {
this.dateHeaders = response.data.sasparams[0].DTVARS.split(' ')
}
if (response.data.sasparams[0].TMVARS !== '') {
this.timeHeaders = response.data.sasparams[0].TMVARS.split(' ')
}
if (response.data.sasparams[0].DTTMVARS !== '') {
this.dateTimeHeaders = response.data.sasparams[0].DTTMVARS.split(' ')
}
if (response.data.xl_rules.length > 0) {
this.xlRules = this.helperService.deepClone(response.data.xl_rules)
}
this.dcValidator = new DcValidator(
response.data.sasparams[0],
response.data.$sasdata,
this.cols,
response.data.dqrules,
response.data.dqdata
)
// this.hotTable.formulas is set once, on the field itself, and applies
// to every table regardless of whether it has any HARDFORMULA/
// SOFTFORMULA rules.
// BASE_COL names of every HARDFORMULA/SOFTFORMULA rule - applyFormulaRules
// below legitimately writes a live `=...` formula string into these, so
// the escape loop further down must skip them (they're never
// backend-sourced text that merely looks like a formula).
const formulaBaseCols = getFormulaColumnNames(response.data.dqrules)
this.cellValidation = this.dcValidator.getRules()
// to take datasource
this.dataSource = response.data.sasdata
this.$dataFormats = response.data.$sasdata
// Raw, as-received snapshot - captured before applyFormulaRules below
// overwrites HARDFORMULA/SOFTFORMULA columns, so markOverwrittenCells
// can later tell "the formula filled in a blank" apart from "the
// formula overwrote a value the real dataset already had".
this.dataSourceRaw = this.helperService.deepClone(this.dataSource)
// Seed every row's EDIT_STATUS to 'U' before anything (HyperFormula
// included) ever sees this data - nothing has been edited yet, and this
// is also the baseline dataSourceUnchanged will later be cloned from, so
// future diffs never see a spurious EDIT_STATUS mismatch (see
// classifyRow's withoutEditStatus for why that would matter).
for (const row of this.dataSource) {
row[EDIT_STATUS_COLUMN_NAME] = 'U'
}
// Seed HARDFORMULA/SOFTFORMULA columns with their computed formula
// string per row. dataSourceUnchanged isn't populated yet this early in
// a fresh load (only the excel-preview/add-row flows set it) - at this
// point, before any edits exist, dataSource IS the unchanged baseline,
// so DC.ORIG_VALUE resolves correctly either way.
applyFormulaRules(
this.dataSource,
response.data.dqrules,
this.headerColumns,
this.dataSourceUnchanged ?? this.dataSource,
this.headerPks,
this.userService.user?.username ?? ''
)
// Prevent a `=`-led value from the backend in any character column from
// being treated as a live formula - see character-column-formula-plan.md.
// Must run after applyFormulaRules above (which legitimately writes a
// live formula string into HARDFORMULA/SOFTFORMULA columns) and before
// dataSourceUnchanged is cloned below, so the "unchanged baseline"
// already reflects the escaped form rather than flagging these cells as
// modified. Only backend-sourced values are ever escaped here - a value
// the user later types or pastes is never touched (see beforeChange).
//
// Also escapes the matching dataSourceRaw cell (still index-aligned
// with dataSource here, before any sort/insert reorders things) for the
// same non-formula columns, so it stays in sync with what the user
// actually sees. dataSourceRaw drives the Revert feature - if it kept
// the true unescaped backend string instead, reverting a cell after
// "Apply as formula" would restore the raw `=...` text, which
// (formulas being enabled everywhere) evaluates live again instead of
// going back to the escaped text the user started from. Formula base
// columns are still skipped, so dataSourceRaw keeps meaning "value
// before the formula overwrote it" for them, same as before.
this.autoEscapedCells = new Map()
this.dataSource.forEach((row, rowIndex) => {
const rowKey = getRowKey(row, this.headerPks)
for (const colName of this.headerColumns) {
if (formulaBaseCols.includes(colName)) continue
if (!isCharacterColumn(this.cols, colName)) continue
const { value, wasEscaped } = escapeCharacterColumnValue(row[colName])
// A row whose backend source never had this column at all (a
// sparse row) must stay that way - writing back unconditionally
// would materialize a new own property set to undefined, which
// the SASjs adapter rejects at submit time even though nothing
// about this cell ever changed.
if (!wasEscaped) continue
row[colName] = value
if (this.dataSourceRaw[rowIndex]) {
this.dataSourceRaw[rowIndex][colName] = value
}
markAutoEscaped(this.autoEscapedCells, rowKey, colName)
}
})
// Seeded here too (not just editTable()) so a HARDFORMULA/SOFTFORMULA
// rule that silently overwrote real pre-existing data (see
// markOverwrittenCells) already shows the row as modified - both the
// '~' row header and EDIT_STATUS='M' - on the very first render, before
// the user ever clicks Edit. editTable() rebuilds this fresh every time
// it runs regardless, so setting it here doesn't affect that.
this.dataSourceUnchanged = this.helperService.deepClone(this.dataSource)
this.overlayFormulaRawValuesOnUnchanged(this.dataSourceUnchanged)
// Note: this.headerColumns and this.columnHeader contains same data
// need to resolve redundancy
// default schema - includes NOTNULL defaults from DQ rules
for (let i = 0; i < this.headerColumns.length; i++) {
const colType = this.cellValidation[i].type
this.hotDataSchema[this.cellValidation[i].data] = getHotDataSchema(
colType,
this.cellValidation[i],
this.dcValidator?.getDqDetails()
)
}
// this.addActionButtons();
// this.addActionColumns();
// now all validation params we have in this.cellValidation
this.checkRowLimit()
hot.updateSettings(
{
data: this.dataSource,
colHeaders: getDisplayColHeaders(
this.headerColumns,
this.cols,
this.useLabels
),
columns: this.cellValidation,
height: this.hotTable.height,
formulas: this.hotTable.formulas,
// The Formulas plugin resolves a =CELL_REF formula (e.g. what
// DC.ROW_STATUS compiles to, see parseFormulaRule.ts) through its own
// data-sync path into HyperFormula - a different path than
// getDataAtRowProp/datamap.get() (see editStatusColumnRule.ts for
// why a dotted `data` key is otherwise safe there). That path
// doesn't check for an own flat property before falling back to
// nested dot-notation, so a referenced cell whose `data` key
// contains a literal '.' (like dc.row_status) resolves to
// undefined/0 instead of its real value. This app never uses nested
// `data` paths, so disabling dot-notation entirely lets
// DC.ROW_STATUS (and anything that branches on it, e.g.
// CHANGE_SUMMARY_COL's IF) resolve correctly without giving up the
// collision-proof dotted name.
dataDotNotation: false,
// readOnly here means comments can never be added/edited/removed
// through the UI - only markOverwrittenCells/
// syncOverwrittenCommentForCell (via the plugin API) ever set one.
// The context menu below only offers our own "Revert" item, never
// the plugin's own add/edit/remove ones.
comments: { readOnly: true },
stretchH: 'all',
readOnly: this.hotTable.readOnly,
hiddenColumns: {
indicators: true,
columns: this.dcValidator.getHiddenColumns()
},
modifyColWidth: function (width: number, col: number) {
if (col === 0) {
return 60
}
if (width > 500) return 500
else return width
},
copyPaste: this.hotTable.copyPaste,
manualColumnFreeze: false, //https://handsontable.com/docs/7.0.3/demo-freezing.html
// false due to https://forum.handsontable.com/t/gh-5112-column-freeze-sorting/3236
multiColumnSorting: true, // https://handsontable.com/docs/7.0.0/demo-multicolumn-sorting.html
manualColumnResize: true,
filters: false,
manualRowResize: true,
viewportRowRenderingOffset: 100,
// Doubles as the edit-status indicator: +/-/~ for
// added/deleted/modified rows. Handsontable re-invokes this on
// every render, so it stays live as rows are edited/added/deleted
// without any extra wiring. Falls back to a plain space (not an
// empty string) for unchanged rows and before dataSource is
// populated, keeping the original row-selection bar's click target
// intact - a space and '' render identically, so this changes
// nothing visually.
//
// `index` is the VISUAL row - once multiColumnSorting reorders the
// grid, that no longer matches dataSource's physical order, so it
// must be translated via toPhysicalRow() before indexing into
// dataSource. Without this, a sorted grid shows every row's symbol
// shifted to the wrong row.
rowHeaders: (index: number) => {
const physicalRow = hot.toPhysicalRow(index)
const dataRow =
physicalRow === null ? undefined : this.dataSource[physicalRow]
if (!dataRow) return ' '
return (
getEditStatusSymbol(
classifyRow(
dataRow,
this.dataSourceUnchanged ?? this.dataSource,
this.headerPks
)
) || ' '
)
},
// Colors the row-header cell to match its edit status (see
// rowHeaders above) - same physicalRow translation for the same
// sorted-grid reason.
afterGetRowHeader: (row: number, th: any) => {
const physicalRow = hot.toPhysicalRow(row)
const dataRow =
physicalRow === null ? undefined : this.dataSource[physicalRow]
if (!dataRow) return
const status = classifyRow(
dataRow,
this.dataSourceUnchanged ?? this.dataSource,
this.headerPks
)
th.classList.remove(
'rowStatusAdded',
'rowStatusDeleted',
'rowStatusModified'
)
if (status === 'A') th.classList.add('rowStatusAdded')
if (status === 'D') th.classList.add('rowStatusDeleted')
if (status === 'M') th.classList.add('rowStatusModified')
},
rowHeaderWidth: 20,
rowHeights: 24,
// Handsontable's own maxRows (a UI/licensing cap on how many NEW
// rows can be ADDED - see checkRowLimit/restrictAddRow) silently
// becomes HyperFormula's OWN sheet-size limit too (see
// getEngineSettingsOverrides in Handsontable's formulas plugin,
// which maps hotSettings.maxRows straight into the engine config).
// editor_rows_allowed can be smaller than a table's ALREADY-
// EXISTING row count, and formulas are on for every table - capping
// at editor_rows_allowed alone would make HyperFormula reject the
// sheet outright for any table bigger than that cap. Never let it
// go below what's actually loaded - the add-row restriction itself
// is still enforced separately, by checkRowLimit's own comparison
// against dataSource.length.
maxRows: Math.max(
this.dataSource.length,
this.licenceState.value.editor_rows_allowed || Infinity
),
invalidCellClassName: 'htInvalid',
// Prevent automatic row creation
autoWrapRow: false,
autoWrapCol: false,
// Ensure proper data binding
bindRowsWithHeaders: false,
dropdownMenu: {
items: {
make_read_only: {
name: 'make_read_only'
},
alignment: {
name: 'alignment'
},
sp1: {
name: '---------'
},
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: (
hot: Handsontable.Core,
wrapper: HTMLElement,
row: number,
col: number,
prop: string | number,
itemValue: string
) => {
const elem = document.createElement('span')
let colName = ''
let colInfo: DataFormat | undefined
let textInfo = 'No info found'
if (this.hotInstance) {
// getSelected() is typed number[][] in HOT 18 (was a 4-tuple
// array before); loosen the annotation to match.
const hotSelected: number[][] =
this.hotInstance.getSelected() || []
const selectedCol: number = hotSelected
? hotSelected[0][1]
: -1
colName = this.hotInstance?.colToProp(selectedCol) as string
colInfo = this.$dataFormats?.vars[colName]
const { hardRegexValue, softRegexValue } =
this.dcValidator?.getRegexRuleValues(colName) || {}
const formulaValue =
this.dcValidator?.getFormulaRuleValue(colName)
textInfo = buildColInfoHtml(
colName,
colInfo,
hardRegexValue,
softRegexValue,
formulaValue
)
}
elem.innerHTML = textInfo
preventMenuItemAutoClose(elem)
return elem
}
}
}
},
// filters: true,
dataSchema: this.hotDataSchema,
contextMenu: this.hotTable.settings.contextMenu,
//, '---------','freeze_column','unfreeze_column'],
currentHeaderClassName: 'customH',
afterGetColHeader: (col: number, th: any) => {
const column = this.columnHeader[col]
// header columns styling - primary keys
const isPKCol = column && this.isColPk(column)
const isReadonlyCol = column && this.isReadonlyCol(column)
if (isPKCol) th.classList.add('primaryKeyHeaderStyle')
if (isReadonlyCol && !isPKCol) th.classList.add('readonlyCell')
// Remove header arrow from Delete column
if (col === 0) {
th.classList.add('firstColumnHeaderStyle')
}
// Dark mode
th.classList.add(globals.handsontable.darkTableHeaderClass)
},
afterGetCellMeta: (
row: number,
col: number,
cellProperties: Handsontable.CellProperties
) => {
const isReadonlyCol = col && this.isReadonlyCol(col)
// Check if this cell should be marked as invalid due to duplicate primary key values
// Only applies to primary key columns (col 1 through readOnlyFields)
const isDuplicateCell =
this.duplicatePkIndexes.includes(row) &&
col >= 1 &&
col <= this.readOnlyFields
// Handle existing CSS classes - Handsontable can provide className as string or array
const existingClasses = cellProperties.className || ''
let classes: string[]
if (Array.isArray(existingClasses)) {
// If already an array, create a copy
classes = [...existingClasses]
} else {
// If string, split by spaces and filter out empty strings
classes = existingClasses
.split(' ')
.filter((c: string) => c.length > 0)
}
// Add readonlyCell class for readonly columns to maintain original styling
if (isReadonlyCol && !classes.includes('readonlyCell')) {
classes.push('readonlyCell')
}
// Apply custom validation styling for duplicate primary key cells
// Note: Uses 'dc-invalid-cell' instead of Handsontable's 'htInvalid' class
// because Handsontable's internal validation system was removing 'htInvalid'
// causing flickering. Our custom class persists reliably.
if (isDuplicateCell) {
if (!classes.includes('dc-invalid-cell')) {
classes.push('dc-invalid-cell')
}
// Mark cell as invalid to prevent form submission
cellProperties.valid = false
// Custom flag to identify this as a duplicate key cell for cleanup
cellProperties.dupKey = true
}
// Apply the combined CSS classes back to the cell
if (classes.length > 0) {
cellProperties.className = classes.join(' ')
}
}
},
false
)
// Must run after the updateSettings() call above: getDataAtRowProp only
// resolves formulas' live computed values once HyperFormula has
// actually evaluated them against the data/formulas settings just
// applied.
this.markOverwrittenCells()
this.resolvePkFormulaSeedValues()
this.hotTable.hidden = false
// Keep the context menu enabled in view mode too so Copy/Export remain
// available; editing items hide themselves when read-only.
this.toggleHotPlugin('contextMenu', true)
/**
* This is needed if freeze column is enabled
*/
// hot.getPlugin('manualColumnFreeze').freezeColumn(0);
this.queryText = response.data.sasparams[0].FILTER_TEXT
this.columnHeader = response.data.sasparams[0].COLHEADERS.split(',')
// First column is always used to mark records for deletion
this.columnHeader[0] = 'Delete?'
this.readOnlyFields = response.data.sasparams[0].PKCNT
hot.addHook(
'afterSelection',
(
row: number,
column: number,
row2: number,
column2: number,
preventScrolling: any,
selectionLayerLevel: any
) => {
/**
* This is needed if freeze column is enabled
*/
// if (column === 0) {
// delete contextMenuToSet.items.unfreeze_column;
// }
if (
row === row2 &&
column === column2 &&
this.hotTable.readOnly === false
) {
this.dynamicCellValidation(row, column)
}
/**
* This is needed if freeze column is enabled
*/
// if (column === 0) {
// if (!this.firstColumnSelected) {
// hot.updateSettings({
// contextMenu: contextMenuToSet
// });
// this.firstColumnSelected = true;
// }
// } else {
// if (this.firstColumnSelected) {
// hot.updateSettings({
// contextMenu: contextMenuToSet
// });
// this.firstColumnSelected = false;
// }
// }
}
)
hot.addHook('afterBeginEditing', () => {
// When we open a dropdown we want filter disabled so value in cell
// don't filter out items, since we want to see them all.
this.setCellFilter(false)
})
hot.addHook('beforeKeyDown', () => {
// When we start typing, we are enabling the filter since we want to find
// values faster.
this.setCellFilter(true)
})
// ROUND: round numeric values Excel-style before they are written.
// Mutating `changes` in place (rather than setDataAtRowProp) avoids
// re-entrancy and uniformly covers edit, paste and autofill.
hot.addHook('beforeChange', (changes: any[], source: any) => {
if (!changes) return
for (const change of changes) {
if (!change) continue
const [changeRow, prop, , newValue] = change
const colName =
typeof prop === 'string'
? prop
: (hot.colToProp(prop as number) as string)
const digits = this.dcValidator?.getRoundDigits(colName)
if (digits !== undefined) {
const num = Number(newValue)
if (newValue !== null && newValue !== '' && !isNaN(num)) {
change[3] = excelRound(num, digits)
}
}
// A `=`-led value the user types or pastes is never auto-escaped
// (see character-column-formula-plan.md) - it's left exactly as
// entered, which genuinely evaluates as a live formula since
// formulas are enabled for every column. If this cell was
// previously auto-escaped from backend data, the user is now
// supplying their own content, so that marker no longer describes
// what's here - clear it so submit/"Apply as formula" leave this
// cell alone.
const row = this.dataSource[hot.toPhysicalRow(changeRow)]
if (!row) continue
const rowKey = getRowKey(row, this.headerPks)
// Revert (see the revert_cells context-menu item, which tags its
// own write with this source) restores dataSourceRaw's value
// verbatim - for a character column that's the same escaped form
// the initial-load pass would have produced, so it's correct to
// treat it the same way and re-mark it, letting "Apply as
// formula" and the submit-time strip work on it again. A value
// that merely happens to start with `'=` because the user typed
// it themselves never takes this path, since only revert_cells
// uses this source.
if (
source === 'revert' &&
typeof newValue === 'string' &&
newValue.startsWith("'=")
) {
markAutoEscaped(this.autoEscapedCells, rowKey, colName)
} else {
clearAutoEscaped(this.autoEscapedCells, rowKey, colName)
}
}
})
hot.addHook('afterChange', (source: any, change: any) => {
if (change === 'edit') {
const hot = this.hotInstance
const row = source[0][0]
const colProp = source[0][1]
const col = hot.propToCol(colProp) as number
// On edit we enabled filter for this cell, now when editing is finished
// We want filter to be disabled again, to be ready for next dropdown opening.
const cellMeta = hot.getCellMeta(row, col)
if (cellMeta && cellMeta.filter === false)
hot.setCellMeta(row, col, 'filter', true)
// Toggling Delete? changes every other cell's validation exemption
// in this row (see DcValidator.setDefaultValidator) — re-validate so
// invalid highlights clear/reappear immediately instead of only at
// submit time.
if (colProp === '_____DELETE__THIS__RECORD_____') {
hot.validateRows([row], () => hot.render())
}
this.executeDynamicCellValidationIfApplicable(colProp, col, row)
}
})
// Keeps each edited row's EDIT_STATUS cell in sync live, so
// DC.ROW_STATUS-based formulas recalculate immediately - not just on
// insert/delete (already handled at their own call sites) but on any
// direct edit, paste or autofill too. 'loadData' fires on every
// hot.updateSettings({data: ...}) call this component already makes
// (initial load, cancelSubmit, ...) where dataSource is already
// consistent, so it's skipped; 'editStatus' is this same hook's own
// 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) => {
if (!changes || source === 'loadData' || source === 'editStatus') return
const revertableCols = this.getRevertableColumnNames()
const changedRows = new Set<number>()
for (const change of changes) {
if (!change) continue
// changes gives the VISUAL row (Handsontable's own documented
// afterChange shape) - translate to physical before indexing
// dataSource, same as beforeChange's own hot.toPhysicalRow(changeRow)
// a few lines up. syncOverwrittenCommentForCell/updateEditStatusForRow
// both expect physical (they translate back to visual internally,
// only where a Handsontable API actually needs it).
const [visualRow, prop] = change
if (prop === EDIT_STATUS_COLUMN_NAME) continue
const row = hot.toPhysicalRow(visualRow)
if (row === null) continue
changedRows.add(row)
if (revertableCols.includes(prop)) {
this.syncOverwrittenCommentForCell(row, prop)
}
}
for (const row of changedRows) this.updateEditStatusForRow(row)
})
hot.addHook('afterPaste', async (_data: any, coords: any) => {
// In read-only mode HOT discards the paste itself, so nothing to validate.
if (this.hotTable.readOnly) return
const ranges = (coords as any[]).map((r) => ({
startRow: r.startRow,
startCol: r.startCol,
endRow: r.endRow,
endCol: r.endCol
}))
await this.runBulkValidation(hot, ranges, 'paste')
})
hot.addHook(
'afterAutofill',
async (_fillData: any, _sourceRange: any, targetRange: any) => {
if (this.hotTable.readOnly) return
const { from, to } = targetRange
await this.runBulkValidation(
hot,
[
{
startRow: Math.min(from.row, to.row),
startCol: Math.min(from.col, to.col),
endRow: Math.max(from.row, to.row),
endCol: Math.max(from.col, to.col)
}
],
'autofill'
)
}
)
hot.addHook('afterRender', (isForced: boolean) => {
// Fix ARIA accessibility issues after each render
this.fixAriaAccessibility()
})
// Add a more frequent accessibility fix hook
hot.addHook('afterChange', () => {
// Fix ARIA accessibility issues after any data change
setTimeout(() => {
this.fixAriaAccessibility()
}, 50)
})
hot.addHook('afterCreateRow', (source: any) => {
if (source > this.dataSource.length) {
// don't scroll if row is not added to the end (bottom)
const wtHolder = document.querySelector('.wtHolder')
setTimeout(() => {
if (wtHolder) wtHolder.scrollTop = wtHolder.scrollHeight
})
}
})
// Add hook to prevent unwanted row creation
hot.addHook(
'beforeCreateRow',
(index: number, amount: number, source?: any) => {
// Only allow row creation through the Add Row button or context menu
if (
!this.addingNewRow &&
source !== 'ContextMenu.insert_row_above' &&
source !== 'ContextMenu.insert_row_below'
) {
return false
}
}
)
// Auto-populate NOTNULL default when validation fails due to empty value
hot.addHook(
'afterValidate',
(isValid: boolean, value: any, row: number, prop: string | number) => {
if (isValid || !isEmpty(value)) return
const colName =
typeof prop === 'string'
? prop
: (hot.colToProp(prop as number) as string)
const defaultValue = this.dcValidator?.getNotNullDefaultValue(colName)
if (defaultValue === undefined) return
// Auto-populate using setTimeout to avoid modifying during validation
setTimeout(() => {
if (isEmpty(hot.getDataAtRowProp(row, colName))) {
hot.setDataAtRowProp(row, colName, defaultValue, 'autoPopulate')
}
}, 0)
}
)
// Coerce numeric-column values from string → number so length-check
// and downstream validators see the correct type on the first pass
// (string "3.5" passes `Number(value) === value`, masking float-in-
// short-num errors until the next edit).
const coerceNumericRow = (
row: any[],
startCol: number,
rowMaxLen?: number
): any[] =>
row.map((value: any, index: number) => {
if (rowMaxLen !== undefined && index >= rowMaxLen) return value
const colName = this.columnHeader[startCol + index]
const isColNum = this.$dataFormats?.vars[colName]?.type === 'num'
const specialMissing = isSpecialMissing(value)
if (isColNum && !isNaN(value) && !specialMissing) value = value * 1
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
for (let r = 0; r < data.length; r++) {
data[r] = coerceNumericRow(data[r], startCol)
// Translate column names in a pasted formula (e.g. "=PRICE * VOLUME")
// into this row's cell references ("=B4 * C4") - only meaningful on
// a character column, since that's what's formula-capable for user
// input (see character-column-formula-plan.md). Translating for a
// numeric column would be pointless and would leave a confusing
// half-translated string sitting there instead of the original,
// readable pasted text.
data[r] = data[r].map((value: any, index: number) => {
const colName = this.columnHeader[startCol + index]
if (!isCharacterColumn(this.cols, colName)) return value
return typeof value === 'string' && value.trim().startsWith('=')
? substituteColumnReferences(
value.trim(),
this.headerColumns,
startRow + r
)
: value
})
}
})
// Same column-name -> cell-reference translation as beforePaste above,
// but for pasting directly into an open cell editor (double-click,
// then Cmd+V/Ctrl+V or right-click > Paste) - Handsontable's CopyPaste
// plugin only intercepts a grid-level paste (cell selected but not
// being edited); pasting into the editor's own textarea is a native
// browser paste event the plugin never sees. Listens once on the root
// element (paste events bubble) rather than attaching a new listener
// per edit session, to avoid accumulating listeners across repeated
// edits.
//
// This whole registration only ever runs once per component instance
// in practice (initSetup's own retry-until-ready loop returns before
// reaching this point on every attempt except the one that succeeds,
// and table-switching navigates to a fresh component instance rather
// than reloading in place) - but removing any previous listener before
// replacing the reference costs nothing and keeps that guarantee from
// becoming a silent leak if this assumption ever changes.
if (this.pasteListener) {
hot.rootElement.removeEventListener('paste', this.pasteListener)
}
this.pasteListener = (event: ClipboardEvent) => {
const editor: any = hot.getActiveEditor()
if (!editor || editor.row === null || event.target !== editor.TEXTAREA)
return
// Only on a character column - see beforePaste's own comment above
// for why.
const colName = this.columnHeader[editor.col]
if (!isCharacterColumn(this.cols, colName)) return
const pastedText = event.clipboardData?.getData('text/plain') ?? ''
if (!pastedText.trim().startsWith('=')) return
event.preventDefault()
editor.TEXTAREA.value = substituteColumnReferences(
pastedText.trim(),
this.headerColumns,
editor.row
)
// A real paste's default action would fire this itself once the
// text landed - since preventDefault() above skips that, dispatch it
// manually so the editor's own input-tracking (autosize, its
// internal notion of the current value) picks up the change.
editor.TEXTAREA.dispatchEvent(new Event('input', { bubbles: true }))
}
hot.rootElement.addEventListener('paste', this.pasteListener)
hot.addHook(
'beforeAutofill',
(selectionData: any[][], sourceRange: any) => {
const startCol = sourceRange.from.col
return selectionData.map((row) => coerceNumericRow(row, startCol))
}
)
hot.addHook('afterRemoveRow', () => {
this.checkRowLimit()
})
hot.addHook('afterCreateRow', () => {
this.checkRowLimit()
})
this.uploadUrl = 'services/editors/loadfile'
if (this.recordAction !== null) {
if (this.recordAction === 'ADD') {
this.addRecord()
this.editRecord(null, this.dataSource.length - 1, true)
} else {
if (this.dataSource.length === 1) {
this.editRecord(null, 0)
}
}
}
if (response.data.query.length > 0) {
if (
(globals.rootParam === 'home' || globals.rootParam === 'editor') &&
globals.editor.filter.clauses.length === 0
) {
globals.editor.filter.query = this.helperService.deepClone(
response.data.query
)
globals.editor.filter.libds = this.route.snapshot.params['libMem']
this.sasStoreService.initializeGlobalFilterClause('editor', this.cols)
}
}
hot.render()
this.observeGridResize()
// Fix ARIA accessibility issues after table initialization
setTimeout(() => {
this.fixAriaAccessibility()
}, 500)
// SAS Visual Analytics data-driven content mode: open in read-only view (NOT
// immediate edit — the user clicks Edit to make changes), re-apply any column
// visibility chosen by VA before this (filter) reload, and start receiving VA
// messages over the postMessage interface.
if (this.isVaEmbed) {
if (this.vaMessaging.visibleColumns) {
this.applyVaColumnVisibility(new Set(this.vaMessaging.visibleColumns))
if (this.hotInstance) this.hotInstance.render()
}
// Listener is already registered early (ngOnInit) so VA's initial post is
// not missed; this is a guarded no-op if so. Now that the grid is ready,
// replay the buffered latest message to apply any filter/visibility that
// arrived during load.
this.subscribeToVaMessages()
// Reconcile to the true latest message after a reload — covers a message
// that arrived during the reload gap (caught by va-early, missed by the
// app listener). Signature dedup makes this a no-op if nothing changed.
this.handleVaMessage()
}
}
/**
* Subscribes to the SAS VA data-driven content postMessage channel. VA pushes
* a fresh message on every selection / parameter change. Registered early (in
* ngOnInit) so the initial message isn't missed; messages are buffered on the
* service and applied via a debounce so a burst of control clicks collapses
* into a single reload on the latest message.
*/
private subscribeToVaMessages() {
if (this.vaUnsubscribe) return
this.vaUnsubscribe = this.vaMessaging.onData(() => this.handleVaMessage())
}
/**
* Handles one VA message immediately (NOT debounced): updates column
* visibility and decides the filter. The cheap work (visibility, building the
* filter clauses, deciding pending) runs per message so the "pending"
* indicator can show the instant VA posts; only the expensive APPLY (saveQuery
* + reload) is debounced (live mode) via scheduleVaFilterApply.
*
* Filter data always comes from DC's getdata; VA only drives (a) column
* visibility and (b) the active filter (its params -> DC clauses, applied by
* reloading at /editor/<table>/<FILTER_RK>).
*/
private handleVaMessage() {
const msg = this.vaMessaging.latestMessage()
if (!msg || !this.libds || !this.cellValidation) return
// Grid not ready yet (cellValidation only has the delete column, or none):
// the label->var map would be empty, so every param would fail to map and
// produce an empty filter that wrongly CLEARS the active one — the infinite
// reload loop. Bail; the load-complete replay re-runs once metadata is in.
if (this.cellValidation.length <= 1) return
const labelToVar = this.vaFilter.buildLabelToVarMap(
this.cellValidation,
this.columnHeader
)
// Columns present in the message -> show these, hide the rest. A message
// with no data columns carries no column info, so leave visibility as-is
// rather than hiding everything.
const dataColumns = this.vaMessaging.dataColumns(msg)
let visibilityChanged = false
if (dataColumns.length > 0) {
const matchedVars = new Set<string>()
for (const { column } of dataColumns) {
const key = (column.label ?? column.name ?? '')
.toString()
.trim()
.toLowerCase()
const varName = labelToVar.get(key)
if (varName) matchedVars.add(varName)
}
this.vaMessaging.visibleColumns = Array.from(matchedVars)
visibilityChanged = this.applyVaColumnVisibility(matchedVars)
}
if (visibilityChanged && this.hotInstance) this.hotInstance.render()
this.updateVaFilterText(msg)
const clauses = this.vaFilter.buildClauses(msg, labelToVar, this.cols)
const signature = this.vaFilter.signature(clauses)
if (signature === this.vaMessaging.filterSignature) {
// Selection matches the applied filter — nothing pending. (Don't clear a
// 'loading' status; that belongs to an apply already in flight.)
if (this.vaFilterStatus === 'pending') this.vaFilterStatus = 'idle'
this.vaPendingClauses = null
return
}
// A real change arrived -> pending (shown in both modes, immediately).
this.vaPendingClauses = clauses
this.vaPendingSignature = signature
this.vaFilterStatus = 'pending'
// Filtering repopulates the grid, so it must never run while the user is
// editing (it would wipe their in-progress edits). While in edit mode we
// only surface the pending indicator; the filter is applied on return to
// read-only (see cancelEdit). Live mode auto-advances after the debounce
// settles; confirm waits for the Apply button.
if (this.vaAutoApply && this.hotTable.readOnly) this.scheduleVaFilterApply()
}
/** Live mode: debounce a burst of VA changes into a single apply of the latest. */
private scheduleVaFilterApply() {
if (this.vaDebounceTimer) clearTimeout(this.vaDebounceTimer)
this.vaDebounceTimer = setTimeout(() => {
this.vaDebounceTimer = undefined
this.applyPendingVaFilter()
}, EditorComponent.VA_DEBOUNCE_MS)
}
/**
* Saves the VA-derived filter through DC's validatefilter flow and reloads the
* editor at /editor/<table>/<FILTER_RK> (preserving ?embed=va) so the filter
* key is visible in the URL. Empty clauses clear the filter.
*/
private async applyVaFilter(clauses: QueryClause[], signature: string) {
// A previous filter is still saving/reloading: drop this call WITHOUT
// advancing the signature. The reloaded editor's load-complete replays the
// latest buffered message, which re-evaluates against the still-accurate
// last-applied signature and applies whatever the newest selection is.
if (this.vaApplyingFilter) return
this.vaApplyingFilter = true
// Now actually fetching: surface the "loading filter" status (until DC's
// native "Loading Table" takes over after the reload navigates).
this.vaFilterStatus = 'loading'
// Record the signature only now that we're actually applying it.
this.vaMessaging.filterSignature = signature
try {
if (clauses.length === 0) {
// Already unfiltered (no FILTER_RK in route)? Do nothing — reloading
// would be a pointless round-trip and re-trigger the drain/replay cycle.
// Only reload to clear a filter that is currently applied.
if (typeof this.filter_pk === 'undefined') {
this.vaFilterStatus = 'idle'
return
}
await this.reloadEditorRoute('/editor/' + this.libds)
return
}
const res: any = await this.sasStoreService.saveQuery(
this.libds!,
clauses
)
const id = res?.result?.[0]?.FILTER_RK
const table = res?.result?.[0]?.FILTER_TABLE
if (id === undefined || table === undefined) {
this.vaFilterStatus = 'idle'
return
}
await this.reloadEditorRoute('/editor/' + table + '/' + id)
} catch {
// Keep the current view on failure; the next message can retry.
this.vaFilterStatus = 'idle'
} finally {
this.vaApplyingFilter = false
}
}
/** Apply the staged VA filter (the debounce in live mode, or the Apply button). */
public applyPendingVaFilter(): void {
if (!this.vaPendingClauses) return
// Never apply while editing — the reload would wipe in-progress edits. The
// pending filter stays staged and is applied when the user leaves edit mode.
if (!this.hotTable.readOnly) return
const clauses = this.vaPendingClauses
const signature = this.vaPendingSignature
this.vaPendingClauses = null
void this.applyVaFilter(clauses, signature)
}
/**
* Toggles between live (auto-apply) and confirm filter modes. The choice is
* stored on globals so it survives editor reloads. Switching to live with a
* change still pending applies it immediately.
*/
public toggleVaAutoApply(): void {
globals.vaApplyMode = this.vaAutoApply ? 'confirm' : 'live'
if (this.vaAutoApply && this.vaFilterStatus === 'pending') {
this.applyPendingVaFilter()
}
}
/** Forces a full editor reload of `target`, preserving the embed query param. */
private async reloadEditorRoute(target: string) {
await this.router.navigate(['/'], {
skipLocationChange: true,
queryParamsHandling: 'preserve'
})
await this.router.navigate([target], { queryParamsHandling: 'preserve' })
}
/**
* Shows the DC data columns present in the latest VA message and hides the
* rest (primary-key and validator-hidden columns are always preserved). VA
* parameters can add/remove columns between pushes.
*/
private applyVaColumnVisibility(matchedVars: Set<string>): boolean {
const hot = this.hotInstance
if (!hot) return false
const plugin: any = hot.getPlugin('hiddenColumns')
if (!plugin) return false
const validatorHidden: number[] = this.dcValidator?.getHiddenColumns() || []
const toHide: number[] = []
const toShow: number[] = []
for (let i = 1; i < this.cellValidation.length; i++) {
const varName = this.cellValidation[i]?.data
if (!varName || this.isColPk(varName)) continue
if (validatorHidden.includes(i)) continue
if (matchedVars.has(varName)) {
if (plugin.isHidden(i)) toShow.push(i)
} else if (!plugin.isHidden(i)) {
toHide.push(i)
}
}
if (toShow.length) plugin.showColumns(toShow)
if (toHide.length) plugin.hideColumns(toHide)
return toShow.length > 0 || toHide.length > 0
}
/** Logs the active VA filter summary (kept out of the embedded grid UI). */
private updateVaFilterText(msg: VaMessage) {
const summary = (msg.parameters || [])
.filter((p) => p && p.name !== undefined && p.value !== undefined)
.map((p) => `${p.label ?? p.name}=${p.value}`)
.join(', ')
// eslint-disable-next-line no-console
console.log('VA filters active:', summary)
}
}