- Formulas' HyperFormula sync resolves a dotted `data` key differently than getDataAtRowProp/datamap.get() - dataDotNotation: false is needed for the renamed dc.row_status column to work as a live cell reference - saveTable() now submits the live computed value for a formula cell, not the raw '=...' string still sitting in dataSource
4337 lines
143 KiB
TypeScript
4337 lines
143 KiB
TypeScript
import {
|
|
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 { findFormulaValueChanges } from '../shared/dc-validator/utils/findFormulaValueChanges'
|
|
import { getFormulaCellsToPreserveOnCancel } from '../shared/dc-validator/utils/getFormulaCellsToPreserveOnCancel'
|
|
import { getStableFormulaBaseCols } from '../shared/dc-validator/utils/getStableFormulaBaseCols'
|
|
import { parseFormulaRule } from '../shared/dc-validator/utils/parseFormulaRule'
|
|
import { DcValidator } from '../shared/dc-validator/dc-validator'
|
|
import { Col } from '../shared/dc-validator/models/col.model'
|
|
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 { 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 { getEditStatusSymbol } from './utils/getEditStatusSymbol'
|
|
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, 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,
|
|
contextMenu: true,
|
|
className: 'htDark',
|
|
theme: 'ht-theme-classic'
|
|
}
|
|
}
|
|
|
|
public hotTable: HotTableInterface = {
|
|
data: [],
|
|
colHeaders: [],
|
|
hidden: true,
|
|
columns: [],
|
|
height: 'calc(100vh - 160px)',
|
|
licenseKey: undefined,
|
|
readOnly: true,
|
|
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 for a cell markFormulaChangedCells attached a
|
|
// comment to (a HARDFORMULA/SOFTFORMULA cell whose formula
|
|
// overwrote a real, pre-existing value) - the comment's presence
|
|
// is a sufficient and exact signal, no need to separately
|
|
// re-check the column against the DQ rules here too.
|
|
revert_formula_value: {
|
|
name: 'Revert value',
|
|
hidden(this: Handsontable.Core) {
|
|
if (this.getSettings().readOnly) return true
|
|
|
|
const fullCellRange: CellRange[] | undefined =
|
|
this.getSelectedRange()
|
|
if (!fullCellRange) return true
|
|
|
|
const { from, to } = fullCellRange[0]
|
|
if (from.row !== to.row || from.col !== to.col) return true
|
|
|
|
const commentsPlugin: any = this.getPlugin('comments')
|
|
|
|
return !commentsPlugin.getCommentAtCell(from.row, from.col)
|
|
},
|
|
callback: (key: string, selection: any[]) => {
|
|
const hot = this.hotInstance
|
|
const { row, col } = selection[0].start
|
|
const prop = hot.colToProp(col) as string
|
|
|
|
const commentsPlugin: any = hot.getPlugin('comments')
|
|
const comment: string | undefined =
|
|
commentsPlugin.getCommentAtCell(row, col)
|
|
if (!comment) return
|
|
|
|
const rawValueText = comment.slice(
|
|
EditorComponent.ORIGINAL_VALUE_COMMENT_PREFIX.length
|
|
)
|
|
const isNumericCol =
|
|
this.dcValidator?.getRule(prop)?.type === 'numeric'
|
|
|
|
hot.setDataAtRowProp(
|
|
row,
|
|
prop,
|
|
isNumericCol ? Number(rawValueText) : rawValueText
|
|
)
|
|
commentsPlugin.removeCommentAtCell(row, col)
|
|
}
|
|
},
|
|
row_above: {
|
|
name: 'Insert Row above',
|
|
hidden: () => this.hotTable.readOnly === true,
|
|
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 markFormulaChangedCells (writes it) and the
|
|
// revert_formula_value context menu item (parses it back out) - see
|
|
// findFormulaValueChanges.
|
|
private static readonly ORIGINAL_VALUE_COMMENT_PREFIX = 'Original value: '
|
|
public tableTrue: boolean | undefined
|
|
public saveLoading = false
|
|
public approvers: string[] = []
|
|
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 = ''
|
|
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 findFormulaValueChanges
|
|
// and getFormulaBaseCols. Distinct from dataSourceUnchanged, which is a
|
|
// per-editing-session baseline that gets reset on every editTable() call;
|
|
// this stays fixed for the table's whole lifetime, since "what did the
|
|
// real dataset actually have before any formula got involved" doesn't
|
|
// change across edit sessions.
|
|
dataSourceRaw!: any[]
|
|
dataSourceBeforeSubmit!: any[]
|
|
dataModified!: any[]
|
|
|
|
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
|
|
|
|
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
|
|
})
|
|
}
|
|
},
|
|
(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)
|
|
})
|
|
|
|
this.dataSourceUnchanged = this.helperService.deepClone(this.dataSource)
|
|
|
|
this.dataSource = previewDatasource
|
|
this.hotTable.data = previewDatasource
|
|
|
|
const hot = this.hotInstance
|
|
|
|
this.excelUploadState = 'Validating-HOT'
|
|
|
|
hot.updateSettings(
|
|
{
|
|
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.hotInstance.updateSettings({
|
|
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
|
|
this.sasStoreService.setQueryVariables(this.libds, this.cols)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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')
|
|
const cfg = plugin?.getSortConfig()
|
|
return Array.isArray(cfg) ? cfg : cfg ? [cfg] : []
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
hot.updateSettings(
|
|
{
|
|
readOnly: this.hotTable.readOnly
|
|
},
|
|
false
|
|
)
|
|
|
|
hot.render()
|
|
|
|
for (const sortConfig of sortConfigs) {
|
|
columnSorting.sort(sortConfig)
|
|
}
|
|
|
|
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 columnSortConfig = columnSorting.getSortConfig()
|
|
const sortConfigs = Array.isArray(columnSortConfig)
|
|
? columnSortConfig
|
|
: [columnSortConfig]
|
|
|
|
if (this.dataSourceUnchanged) {
|
|
// dataSourceUnchanged deliberately holds the RAW pre-formula value for
|
|
// a HARDFORMULA/SOFTFORMULA column that still has its
|
|
// markFormulaChangedCells comment (see editTable's overlay) - needed
|
|
// so classifyRow flags the row as modified, but it isn't a session
|
|
// edit to discard: the formula recomputes on every load regardless.
|
|
// Snapshot those cells' live computed value before the blind restore
|
|
// below, then patch them back in - otherwise cancelling would freeze
|
|
// the display at the raw value and erase the row's modified marker.
|
|
const formulaBaseCols = this.getFormulaBaseCols()
|
|
const commentsPlugin = hot.getPlugin('comments')
|
|
const toPreserve = getFormulaCellsToPreserveOnCancel(
|
|
this.dataSource.length,
|
|
formulaBaseCols,
|
|
(rowIndex, baseCol) =>
|
|
!!commentsPlugin.getCommentAtCell(
|
|
rowIndex,
|
|
hot.propToCol(baseCol) as number
|
|
),
|
|
(rowIndex, prop) => hot.getDataAtRowProp(rowIndex, prop)
|
|
)
|
|
|
|
this.dataSource = this.helperService.deepClone(this.dataSourceUnchanged)
|
|
|
|
for (const cell of toPreserve) {
|
|
if (this.dataSource[cell.rowIndex]) {
|
|
this.dataSource[cell.rowIndex][cell.prop] = cell.value
|
|
}
|
|
}
|
|
}
|
|
|
|
this.hotTable.data = this.dataSource
|
|
this.hotTable.readOnly = true
|
|
|
|
hot.updateSettings(
|
|
{
|
|
readOnly: this.hotTable.readOnly,
|
|
data: this.dataSource
|
|
},
|
|
false
|
|
)
|
|
|
|
this.modifedRowsIndexes = []
|
|
hot.validateCells()
|
|
// this.editRecordListeners();
|
|
for (const sortConfig of sortConfigs) {
|
|
columnSorting.sort(sortConfig)
|
|
}
|
|
|
|
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)
|
|
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 by markFormulaChangedCells and the dataSourceUnchanged
|
|
* overlay in editTable() - NOT the same filter seedFormulaValuesForRow
|
|
* uses above, which seeds every formula column regardless of this
|
|
* distinction.
|
|
*/
|
|
private getFormulaBaseCols(): string[] {
|
|
const dqRules = this.dcValidator?.getDqDetails()
|
|
if (!dqRules) return []
|
|
|
|
return getStableFormulaBaseCols(dqRules)
|
|
}
|
|
|
|
/**
|
|
* Overlays the true raw (pre-formula) value onto dataSourceUnchanged for
|
|
* every HARDFORMULA/SOFTFORMULA base col that already had real data (see
|
|
* markFormulaChangedCells) - mutates in place. A HARDFORMULA/SOFTFORMULA
|
|
* rule can silently overwrite a value that already existed in the real
|
|
* dataset - that's a real change classifyRow's diff should pick up, not
|
|
* something that only becomes visible once an edit session starts. Shared
|
|
* by the initial load (so the row header agrees with EDIT_STATUS from the
|
|
* very first render) and editTable() (every time the baseline is rebuilt).
|
|
*/
|
|
private overlayFormulaRawValuesOnUnchanged(dataSourceUnchanged: any[]): void {
|
|
const formulaBaseCols = this.getFormulaBaseCols()
|
|
if (formulaBaseCols.length === 0 || !this.dataSourceRaw) return
|
|
|
|
dataSourceUnchanged.forEach((row, rowIndex) => {
|
|
const rawRow = this.dataSourceRaw[rowIndex]
|
|
if (!rawRow) return
|
|
|
|
for (const baseCol of formulaBaseCols) {
|
|
const rawValue = rawRow[baseCol]
|
|
if (rawValue === undefined || rawValue === null || rawValue === '')
|
|
continue
|
|
|
|
row[baseCol] = rawValue
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Marks every cell where a HARDFORMULA/SOFTFORMULA rule silently changed
|
|
* a value that already existed in the real dataset (see
|
|
* findFormulaValueChanges) with a read-only comment showing the original
|
|
* value - otherwise there's no visual difference between "the formula
|
|
* just filled in a blank" and "the formula overwrote real data no one
|
|
* asked to change". Must run after hot.updateSettings() has processed the
|
|
* formulas: getDataAtRowProp only resolves the live computed value once
|
|
* HyperFormula has actually evaluated the cell, not the raw formula
|
|
* string dataSource holds right after applyFormulaRules.
|
|
*
|
|
* Also flips each affected row's EDIT_STATUS to 'M' - this is a real,
|
|
* permanent difference from the raw dataset (every future load recomputes
|
|
* the same formula again), not a session edit afterChange would ever see,
|
|
* so nothing else would otherwise mark these rows modified. Doing this
|
|
* once here, before dataSourceUnchanged is ever cloned from dataSource,
|
|
* means the 'M' baseline is already shared by both snapshots - no special
|
|
* handling needed to keep it from being wiped out by a later cancelEdit().
|
|
*/
|
|
private markFormulaChangedCells(): void {
|
|
const hot = this.hotInstance
|
|
const formulaBaseCols = this.getFormulaBaseCols()
|
|
if (formulaBaseCols.length === 0 || !this.dataSourceRaw) return
|
|
|
|
const computedRows = this.dataSource.map((_row, rowIndex) =>
|
|
Object.fromEntries(
|
|
formulaBaseCols.map((baseCol) => [
|
|
baseCol,
|
|
hot.getDataAtRowProp(rowIndex, baseCol)
|
|
])
|
|
)
|
|
)
|
|
|
|
const changes = findFormulaValueChanges(
|
|
computedRows,
|
|
this.dataSourceRaw,
|
|
formulaBaseCols
|
|
)
|
|
|
|
const commentsPlugin = hot.getPlugin('comments')
|
|
const changedRows = new Set<number>()
|
|
|
|
for (const change of changes) {
|
|
commentsPlugin.setCommentAtCell(
|
|
change.rowIndex,
|
|
hot.propToCol(change.baseCol) as number,
|
|
`${EditorComponent.ORIGINAL_VALUE_COMMENT_PREFIX}${change.originalValue}`
|
|
)
|
|
changedRows.add(change.rowIndex)
|
|
}
|
|
|
|
// Not classifyRow/updateEditStatusForRow - dataSourceUnchanged doesn't
|
|
// exist yet this early (only editTable() sets it), and these rows are
|
|
// already known-modified from the comment loop above; no need to
|
|
// reclassify.
|
|
//
|
|
// Deferred: setDataAtRowProp needs the Formulas plugin's hidden-column
|
|
// index mapping to have completed at least one settle pass. Called
|
|
// synchronously, right after the initial hot.updateSettings(), that
|
|
// mapping isn't ready yet and HyperFormula throws
|
|
// ExpectedValueOfTypeError (address.col resolves to undefined for the
|
|
// trimmed/hidden dc.row_status column) - since ngOnInit's getdata
|
|
// .catch() swallows anything thrown here, that leaves the whole table
|
|
// silently hidden. editTable() defers its own post-render work the
|
|
// same way, for the same index-mapping reason.
|
|
if (changedRows.size > 0) {
|
|
setTimeout(() => {
|
|
for (const rowIndex of changedRows) {
|
|
hot.setDataAtRowProp(
|
|
rowIndex,
|
|
EDIT_STATUS_COLUMN_NAME,
|
|
'M',
|
|
'editStatus'
|
|
)
|
|
}
|
|
}, 0)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Recomputes this row's classification (M/A/D/U) and writes it into the
|
|
* EDIT_STATUS cell - the same classification the row-header symbol shows,
|
|
* 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.
|
|
*/
|
|
private updateEditStatusForRow(rowIndex: number): void {
|
|
const dataRow = this.dataSource[rowIndex]
|
|
if (!dataRow) return
|
|
|
|
const status = classifyRow(
|
|
dataRow,
|
|
this.dataSourceUnchanged ?? this.dataSource,
|
|
this.headerPks
|
|
)
|
|
|
|
this.hotInstance.setDataAtRowProp(
|
|
rowIndex,
|
|
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
|
|
hot.updateSettings(
|
|
{
|
|
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
|
|
}
|
|
}
|
|
|
|
hot.updateSettings(
|
|
{
|
|
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
|
|
)
|
|
}
|
|
})
|
|
}
|
|
|
|
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)
|
|
|
|
const hot = this.hotInstance
|
|
|
|
hot.updateSettings(
|
|
{
|
|
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
|
|
|
|
const hot = this.hotInstance
|
|
|
|
hot.updateSettings(
|
|
{
|
|
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])
|
|
const clickedColumnKey = Object.keys(clickedRow)[column]
|
|
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
|
|
}
|
|
],
|
|
source_row: [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
|
|
}
|
|
}
|
|
|
|
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.hotInstance.updateSettings({ 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
|
|
}
|
|
|
|
// 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
|
|
hot.updateSettings({ 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
|
|
)
|
|
|
|
// Only turn on Handsontable's formulas plugin (HyperFormula engine) for
|
|
// tables that actually use HARDFORMULA/SOFTFORMULA - it's not free to
|
|
// run for every grid. gpl-v3: this app embeds HyperFormula under its
|
|
// GPLv3 free-tier terms, not a purchased commercial key.
|
|
this.hotTable.formulas = hasFormulaRules(response.data.dqrules)
|
|
? { engine: HyperFormula, licenseKey: 'gpl-v3' }
|
|
: false
|
|
|
|
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 markFormulaChangedCells
|
|
// can later tell "the formula filled in a blank" apart from "the
|
|
// formula overwrote a value the real dataset already had".
|
|
this.dataSourceRaw = this.helperService.deepClone(this.dataSource)
|
|
|
|
// Seed every row's EDIT_STATUS to 'U' before anything (HyperFormula
|
|
// included) ever sees this data - nothing has been edited yet, and this
|
|
// is also the baseline dataSourceUnchanged will later be cloned from, so
|
|
// 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 ?? ''
|
|
)
|
|
|
|
// Seeded here too (not just editTable()) so a HARDFORMULA/SOFTFORMULA
|
|
// rule that silently overwrote real pre-existing data (see
|
|
// markFormulaChangedCells) already shows the row as modified - both the
|
|
// '~' row header and EDIT_STATUS='M' - on the very first render, before
|
|
// the user ever clicks Edit. editTable() rebuilds this fresh every time
|
|
// it runs regardless, so setting it here doesn't affect that.
|
|
this.dataSourceUnchanged = this.helperService.deepClone(this.dataSource)
|
|
this.overlayFormulaRawValuesOnUnchanged(this.dataSourceUnchanged)
|
|
|
|
// Note: this.headerColumns and this.columnHeader contains same data
|
|
// need to resolve redundancy
|
|
|
|
// 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 markFormulaChangedCells (via the plugin
|
|
// API) ever sets one. The context menu below only offers our own
|
|
// "Revert value" item, never the plugin's own add/edit/remove ones.
|
|
comments: { readOnly: true },
|
|
stretchH: 'all',
|
|
readOnly: this.hotTable.readOnly,
|
|
hiddenColumns: {
|
|
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
|
|
)
|
|
) || ' '
|
|
)
|
|
},
|
|
rowHeaderWidth: 15,
|
|
rowHeights: 24,
|
|
maxRows: 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',
|
|
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
|
|
|
|
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.markFormulaChangedCells()
|
|
|
|
this.hotTable.hidden = false
|
|
// Keep the context menu enabled in view mode too so Copy/Export remain
|
|
// available; editing items hide themselves when read-only.
|
|
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[]) => {
|
|
if (!changes) return
|
|
|
|
for (const change of changes) {
|
|
if (!change) continue
|
|
|
|
const [, 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) continue
|
|
|
|
const num = Number(newValue)
|
|
if (newValue !== null && newValue !== '' && !isNaN(num)) {
|
|
change[3] = excelRound(num, digits)
|
|
}
|
|
}
|
|
})
|
|
|
|
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.
|
|
hot.addHook('afterChange', (changes: any[], source: any) => {
|
|
if (!changes || source === 'loadData' || source === 'editStatus') return
|
|
|
|
const changedRows = new Set<number>()
|
|
for (const change of changes) {
|
|
if (!change) continue
|
|
|
|
const [row, prop] = change
|
|
if (prop === EDIT_STATUS_COLUMN_NAME) continue
|
|
|
|
changedRows.add(row)
|
|
}
|
|
|
|
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, change: 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
|
|
})
|
|
|
|
hot.addHook('beforePaste', (data: any, cords: any) => {
|
|
const startCol = cords[0].startCol
|
|
for (let r = 0; r < data.length; r++) {
|
|
data[r] = coerceNumericRow(data[r], startCol)
|
|
}
|
|
})
|
|
|
|
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)
|
|
}
|
|
}
|