Files
dc/client/src/app/shared/viewboxes/viewboxes.component.ts
M b419cd5078
Some checks failed
Build / Build-and-ng-test (pull_request) Failing after 2m3s
Build / Build-and-test-development (pull_request) Failing after 1m34s
fix: remaining hot migrations - handsontable/angular-wrapper
2025-08-06 14:06:07 +02:00

1492 lines
42 KiB
TypeScript

import {
CdkDragDrop,
CdkDragEnd,
CdkDragMove,
moveItemInArray,
transferArrayItem
} from '@angular/cdk/drag-drop'
import {
AfterViewInit,
ChangeDetectorRef,
Component,
ElementRef,
EventEmitter,
Input,
NgZone,
OnDestroy,
OnInit,
Output,
QueryList,
ViewChildren,
ViewEncapsulation
} from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { SASjsConfig } from '@sasjs/adapter'
import Handsontable from 'handsontable'
import { HotTableComponent } from '@handsontable/angular-wrapper'
import { cloneDeep } from 'lodash-es'
import { Subscription } from 'rxjs'
import { FilterQuery, FilterGroup } from 'src/app/models/FilterQuery'
import { Libinfo } from 'src/app/models/sas/common/Libinfo'
import { PublicViewtablesServiceResponse } from 'src/app/models/sas/public-viewtables.model'
import { EventService } from 'src/app/services/event.service'
import { HelperService } from 'src/app/services/helper.service'
import { LicenceService } from 'src/app/services/licence.service'
import { LoggerService } from 'src/app/services/logger.service'
import { SasStoreService } from 'src/app/services/sas-store.service'
import { SasService } from 'src/app/services/sas.service'
import { AutocompleteComponent } from 'src/app/shared/autocomplete/autocomplete.component'
import { LibraryClickEmitter } from 'src/app/shared/dc-tree/models/LibraryClickEmitter'
import { TableClickEmitter } from 'src/app/shared/dc-tree/models/TableClickEmitter'
import { mergeColsRules } from 'src/app/shared/dc-validator/utils/mergeColsRules'
import { globals, initFilter } from 'src/app/_globals'
import { ViewboxHotTable } from './models/viewbox-hot-table.model'
import { ViewboxTable } from './models/viewbox-table.model'
import { Viewbox } from './models/viewbox.model'
@Component({
selector: 'app-viewboxes',
templateUrl: './viewboxes.component.html',
styleUrls: ['./viewboxes.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class ViewboxesComponent implements OnInit, AfterViewInit, OnDestroy {
@ViewChildren('resizeBox') resizeBoxQuery!: QueryList<ElementRef> //make query list, handle multiple
@ViewChildren('dragHandleCorner')
dragHandleCornerQuery!: QueryList<ElementRef>
@ViewChildren(HotTableComponent)
hotTableComponents!: QueryList<HotTableComponent>
private _viewboxModal: boolean = false
get viewboxModal(): boolean {
return this._viewboxModal
}
@Input() set viewboxModal(value: boolean) {
// If feature is disabled, prevent modal show up and show demo notice
if (this.licenceState.value.viewbox === false && !!value) {
this.eventService.showDemoLimitModal('Viewboxes')
this.viewboxModalChange.emit(false)
return
}
this._viewboxModal = value
if (!!value) this.unsetSelectedViewbox()
}
@Output() viewboxModalChange: EventEmitter<boolean> =
new EventEmitter<boolean>()
public libraries!: Array<any>
public tables: any
public libinfo: Libinfo[] | null = null
public librariesLoading: boolean = true
public viewboxes: Viewbox[] = []
public selectedViewbox: Viewbox | undefined
public selectedViewboxTable: ViewboxTable | undefined
public defaultConfig: Viewbox = {
id: -1,
library: '',
table: '',
width: 500,
height: 300,
x: 0,
y: 150,
columns: []
}
public sasjsConfig: SASjsConfig = new SASjsConfig()
public hotTableDefault: ViewboxHotTable = {
data: [],
headerPks: [],
$dataformats: {},
allColHeaders: [],
colHeadersHidden: [],
colHeadersVisible: [],
colHeaders: [],
contextMenu: ['copy_with_column_headers', 'copy_column_headers_only'],
copyPaste: {
copyColumnHeaders: true,
copyColumnHeadersOnly: true
},
columns: [],
cols: [],
height: 200, //WORKAROUND: Changed from '100%' to fixed pixel value because otherwize hot does not load
settings: {},
hiddenColumns: true,
manualColumnMove: false,
afterGetColHeader: undefined,
licenseKey: undefined,
dropdownMenu: undefined
}
public viewboxHotSettings: Map<number, Handsontable.GridSettings> = new Map()
public viewboxTables: ViewboxTable[] = []
public filteringViewbox: Viewbox | undefined
public filter: boolean = false
public filterLoading: boolean = false
public clauses: any
public nullVariables: boolean = false
public filterLibds: string | undefined
public _query: Subscription | undefined
public licenceState = this.licenceService.licenceState
public Infinity = Infinity
public maxViewboxes: number =
this.licenceState.value.viewbox_limit === Infinity
? 6
: this.licenceState.value.viewbox_limit || 6
constructor(
private ngZone: NgZone,
private licenceService: LicenceService,
private sasService: SasService,
private eventService: EventService,
private sasStoreService: SasStoreService,
private loggerService: LoggerService,
private helperService: HelperService,
private router: Router,
private activatedRoute: ActivatedRoute,
private cdf: ChangeDetectorRef
) {}
ngOnInit(): void {
// Load libraries
this.sasStoreService
.viewLibs()
.then((res: any) => {
this.libraries = res.saslibs
})
.catch((err: any) => {
this.loggerService.error(err)
})
.finally(() => {
this.librariesLoading = false
})
// Listen for filtering data
this._query = this.sasStoreService.query.subscribe((query: any) => {
this.clauses = query.obj
this.filterLibds = query.libds
})
this.sasjsConfig = this.sasService.getSasjsConfig()
this.licenceService.hot_license_key.subscribe(
(hot_license_key: string | undefined) => {
this.hotTableDefault.licenseKey = hot_license_key
}
)
const viewboxesQueryParam =
this.activatedRoute.snapshot.queryParams.viewboxes
if (viewboxesQueryParam) {
if (this.licenceState.value.viewbox === false) {
setTimeout(() =>
this.eventService.showDemoLimitModal('Linking Viewboxes')
)
this.router.navigate([], {
relativeTo: this.activatedRoute,
queryParams: {}
})
} else {
this.viewboxes = this.decodeUrlData(viewboxesQueryParam)
setTimeout(() => {
this.setAllHandleTransform()
})
}
}
this.reLoadViewboxtables(this.viewboxes)
}
ngAfterViewInit(): void {
// Set handles for box resize and ensure HOT components are properly initialized
setTimeout(() => {
this.setAllHandleTransform()
// Force refresh of any existing HOT instances after view init
this.viewboxes.forEach((viewbox) => {
if (this.getViewboxTableIndex(viewbox) > -1) {
this.refreshTableAfterResize(viewbox)
}
})
}, 1000)
}
// Maximum number of open viewboxes reached
get viewboxLimitReached(): boolean {
return this.viewboxes.length >= this.maxViewboxes
}
clrModalOpenChange(open: boolean) {
this.viewboxModalChange.emit(open)
}
libraryOnClick(data: LibraryClickEmitter) {
if (!data.tablesLoaded)
this.loadTables(data.library.LIBRARYREF, data.library)
}
/**
* Adding new Viewbox - Fired when table is selected from the `dc-tree`
* @param data Selected table data coming from the `dc-tree`
*/
async tableOnClick(data: TableClickEmitter) {
if (this.viewboxLimitReached) return
const viewbox = {
...this.defaultConfig,
table: data.libTable,
library: data.library.LIBRARYNAME,
loadingData: true,
filter_pk: '0',
id: this.viewboxes.length + 1,
x: window.innerWidth - this.defaultConfig.width,
y: 150
}
this.viewboxes.push(viewbox)
setTimeout(() => {
this.setAllHandleTransform()
})
const libDataset = `${data.library.LIBRARYREF}.${data.libTable}`
await this.loadData(libDataset, viewbox)
viewbox.loadingData = false
this.eventService.dispatchEvent('resize') //Force HOT refresh
this.snapToGrid() //it will call viewboxChanged
}
/**
* Laods tables to populate `dc-tree`
* @param lib
* @param library
*/
loadTables(lib: string, library?: any) {
this.sasStoreService
.viewTables(lib)
.then((res: PublicViewtablesServiceResponse) => {
let tables = res.mptables.map(function (item: any) {
return item.MEMNAME
})
this.libinfo = res.libinfo || []
this.tables = tables
if (library) {
library['tables'] = tables
library['libinfo'] = this.libinfo
library['loadingTables'] = false
if (tables.length > 0) library['expanded'] = true
}
})
.catch((err: any) => {
this.loggerService.error(err)
})
}
/**
* Re-loads data particular Viewbox (preserving the filtering PK)
* @param libDataset library.table
* @param viewbox viewbox to reload data for
* @returns Promise
*/
async reloadData(libDataset: string, viewbox: Viewbox) {
return new Promise((resolve, reject) => {
let viewboxTable = this.viewboxTables.find(
(vbt) => vbt.viewboxId === viewbox.id
)
this.sasStoreService
.viewData(libDataset, parseInt(viewbox.filter_pk || '0'))
.then((res: any) => {
if (viewboxTable) {
viewboxTable.hotTable.data = res.viewdata
// Update settings with new data
this.createViewboxTableSettings(viewbox)
resolve(null)
} else {
resolve(null)
}
})
.catch(() => {
reject()
})
})
}
/**
* Initial data load for particular Viewbox
* If data has been already found for particular Viewbox
* and filtering is not active it will return stored data
* instead of sending new request
*
* @param libDataset library.table
* @param viewbox
* @returns Empty Promise - used for awaiting only
*/
async loadData(libDataset: string, viewbox: Viewbox): Promise<void> {
return new Promise((resolve, reject) => {
let existingViewboxTable: boolean = false
let viewboxTable = this.viewboxTables.find(
(vbt) => vbt.viewboxId === viewbox.id
)
if (viewboxTable && (viewbox.filter_pk === '0' || !viewbox.filter_pk))
resolve()
this.sasStoreService
.viewData(libDataset, parseInt(viewbox.filter_pk || '0'))
.then((res: any) => {
if (!viewboxTable) {
viewboxTable = {
viewboxId: viewbox.id,
viewboxLibDataset: libDataset,
hotTable: cloneDeep(this.hotTableDefault)
}
} else {
existingViewboxTable = true
}
viewboxTable.hotTable.data = res.viewdata
viewboxTable.hotTable.$dataformats = res.$viewdata
viewboxTable.hotTable.cols = res.cols
mergeColsRules(viewboxTable.hotTable.cols, [], res.$viewdata)
let columns: any[] = []
let colArr: string[] = []
for (let key in res.viewdata[0]) {
if (key) {
colArr.push(key)
}
}
for (let index = 0; index < colArr.length; index++) {
columns.push({ data: colArr[index] })
}
viewboxTable.hotTable.headerPks = cloneDeep(
res.sasparams[0].PK_FIELDS.split(' ')
)
viewboxTable.hotTable.allColHeaders = colArr.filter(
(col) => !viewboxTable!.hotTable.headerPks.includes(col)
)
viewboxTable.hotTable.colHeadersHidden = cloneDeep(
viewboxTable.hotTable.allColHeaders
)
viewboxTable.hotTable.colHeadersVisible = colArr.filter((col) =>
viewboxTable!.hotTable.headerPks.includes(col)
)
viewboxTable.hotTable.colHeaders = colArr
viewboxTable.hotTable.columns = columns
if (viewbox.columns && viewbox.columns.length > 0) {
viewboxTable.hotTable.manualColumnMove = viewbox.columns
viewbox.columns?.map((col: number, index: number) => {
const colProp = colArr[col]
const hiddenColIndex =
viewboxTable!.hotTable.colHeadersHidden.indexOf(colProp)
if (hiddenColIndex > -1) {
viewboxTable!.hotTable.colHeadersHidden.splice(
hiddenColIndex,
1
)
viewboxTable!.hotTable.colHeadersVisible[index] = colProp
}
})
} else {
viewboxTable.hotTable.colHeadersVisible.push(
...viewboxTable.hotTable.colHeadersHidden.splice(0, 10)
)
}
viewboxTable.hotTable.colHeadersVisible =
viewboxTable.hotTable.colHeadersVisible.filter((x) => x) //remove empty slots
if (!existingViewboxTable) this.viewboxTables.push(viewboxTable)
viewbox.query = this.helperService.deepClone(res.query)
viewbox.filterText = res.sasparams[0].FILTER_TEXT
// Create settings for this viewbox
this.createViewboxTableSettings(viewbox)
setTimeout(() => {
this.updateHotColumns(
viewboxTable!.hotTable.colHeadersHidden || [],
viewbox.id
)
// HOT Settings are bound in HTML but some settings due to timing issues
// requires to be updated after the HOT is instanced
// Use a longer timeout to ensure the HOT component is fully initialized
setTimeout(() => {
const hotInstance = this.getViewboxHotInstance(viewbox.id)
if (hotInstance) {
hotInstance.updateSettings({
manualColumnMove: viewboxTable!.hotTable.manualColumnMove,
afterGetColHeader: (col: number, th: any) => {
const column = hotInstance?.colToProp(col) as string
// header columns styling - primary keys
const isPKCol =
column &&
viewboxTable!.hotTable.headerPks.indexOf(column) > -1
if (isPKCol) th.classList.add('primaryKeyHeaderStyle')
// Dark mode
th.classList.add(globals.handsontable.darkTableHeaderClass)
}
})
hotInstance.render()
}
if (this.selectedViewbox) {
this.resetSelectedViewbox(viewbox)
}
}, 500)
}, 100)
resolve()
})
.catch((err: any) => {
this.loggerService.error(err)
reject()
})
})
}
/**
* Reloads SAS data for each viewbox that exists
* @param viewboxes viewboxes array
*/
reLoadViewboxtables(viewboxes: Viewbox[]) {
viewboxes.map((viewbox: Viewbox) => {
const libDataset = `${viewbox.library}.${viewbox.table}`
viewbox.loadingData = true
this.loadData(libDataset, viewbox).then(() => {
viewbox.loadingData = false
})
})
}
// HOT cols max width
maxWidthCheker(width: any, col: any) {
if (width > 200) return 200
else return width
}
/**
* Used to pair `Viewbox` with it's data
* Which is stored in different array - ViewboxTables
* @param viewbox
*/
getViewboxTableIndex(viewbox: Viewbox): number {
const index = this.viewboxTables.findIndex(
(x) => x.viewboxId === viewbox.id
)
return index
}
/**
* Create and store Handsontable settings for a specific viewbox
* @param viewbox
*/
private createViewboxTableSettings(viewbox: Viewbox): void {
const viewboxTableIndex = this.getViewboxTableIndex(viewbox)
if (viewboxTableIndex === -1) {
this.viewboxHotSettings.set(viewbox.id, {})
return
}
const viewboxTable = this.viewboxTables[viewboxTableIndex]
const calculatedHeight = this.calculateTableHeight(viewbox)
// HOT v16 settings - data will be loaded manually after initialization
const settings: Handsontable.GridSettings = {
colHeaders: viewboxTable.hotTable.colHeaders,
columns: viewboxTable.hotTable.columns,
height: calculatedHeight,
readOnly: true,
modifyColWidth: this.maxWidthCheker,
copyPaste: viewboxTable.hotTable.copyPaste,
contextMenu: viewboxTable.hotTable.contextMenu,
multiColumnSorting: true,
viewportRowRenderingOffset: 50,
filters: true,
dropdownMenu: viewboxTable.hotTable.dropdownMenu,
stretchH: 'all',
cells: viewboxTable.hotTable.cells,
maxRows: viewboxTable.hotTable.maxRows || Infinity,
manualColumnResize: true,
rowHeaders: true,
licenseKey: viewboxTable.hotTable.licenseKey
}
// Force a new object reference to trigger change detection
this.viewboxHotSettings.set(viewbox.id, { ...settings })
// Force change detection to ensure the template updates
setTimeout(() => {
this.cdf.detectChanges()
// Try to get the HOT instance and force a render
setTimeout(() => {
const hotInstance = this.getViewboxHotInstance(viewbox.id)
if (hotInstance) {
// Load data manually - this is required for HOT v16 Angular wrapper
hotInstance.loadData(viewboxTable.hotTable.data)
hotInstance.render()
}
}, 500)
})
}
/**
* Get stored Handsontable settings for a specific viewbox
* @param viewbox
*/
getViewboxTableSettings(viewbox: Viewbox): Handsontable.GridSettings {
return this.viewboxHotSettings.get(viewbox.id) || {}
}
/**
* Viewbox resize
* @param dragHandle
* @param target
*/
resize(
dragHandle: HTMLElement,
target: HTMLElement
): { width: number; height: number } {
const dragRect = dragHandle.getBoundingClientRect()
const targetRect = target.getBoundingClientRect()
const width = dragRect.left - targetRect.left + dragRect.width
const height = dragRect.top - targetRect.top + dragRect.height
target.style.width = width + 'px'
target.style.height = height + 'px'
this.setAllHandleTransform()
this.helperService.debounceCall(1000, () => {
this.viewboxChanged()
this.eventService.dispatchEvent('resize')
// Refresh all viewbox tables after resize and update their settings
this.viewboxes.forEach((viewbox) => {
// Settings will include updated heights when accessed
this.refreshTableAfterResize(viewbox)
})
})
return {
width,
height
}
}
/**
* Calls `resize()` outside of angular zone
* Running functions via #runOutsideAngular allows you to escape Angular's
* zone and do work that doesn't trigger Angular change-detection or is subject
* to Angular's error handling.
* @param dragHandle
* @param resizeBox
* @param viewbox
* @param $event
*/
dragMove(
dragHandle: HTMLElement,
resizeBox: any,
viewbox: Viewbox,
$event: CdkDragMove<any>
) {
this.ngZone.runOutsideAngular(() => {
const newDimnesion = this.resize(dragHandle, resizeBox)
viewbox.width = newDimnesion.width
viewbox.height = newDimnesion.height
})
}
/**
* Sets the 'resize' handle in the correct corner position of the all boxes
*/
setAllHandleTransform() {
this.resizeBoxQuery.forEach((resizeBox: ElementRef) => {
const rect = resizeBox.nativeElement.getBoundingClientRect()
const handleId = `handle_${resizeBox.nativeElement.id}`
const dragHandleCorner = this.dragHandleCornerQuery.find(
(el, i) => el.nativeElement.id === handleId
)
this.setHandleTransform(dragHandleCorner?.nativeElement, rect, 'both')
})
}
/**
* Sets the 'resize' handle in the correct corner position of the box
*/
setHandleTransform(
dragHandle: HTMLElement,
targetRect: ClientRect | DOMRect,
position: 'x' | 'y' | 'both'
) {
const dragRect = dragHandle.getBoundingClientRect()
let translateX = targetRect.width - dragRect.width
let translateY = targetRect.height - dragRect.height
//Fine tune
translateX += 5
translateY += 5
if (position === 'x') {
dragHandle.style.transform = `translate(${translateX}px, 0)`
}
if (position === 'y') {
dragHandle.style.transform = `translate(0, ${translateY}px)`
}
if (position === 'both') {
dragHandle.style.transform = `translate(${translateX}px, ${translateY}px)`
}
}
/**
* When clicked on Viewbox, it will focus it
* Focused Viewbox is always bring to top
* @param viewbox
*/
focusViewbox(viewbox: Viewbox) {
this.viewboxes.map((vbox) => {
vbox.focused = false
})
viewbox.focused = true
}
/**
* On drag end Viewbox position is updated
* As well as the URL
* @param event
* @param viewbox
*/
viewboxDragEnded(event: CdkDragEnd, viewbox: Viewbox) {
let element = event.source.getRootElement()
let boundingClientRect = element.getBoundingClientRect()
viewbox.x = boundingClientRect.left
viewbox.y = boundingClientRect.top
this.viewboxChanged()
}
/**
* Snap to grid calculates the best grid possible
* for given screen width and height
* Configurable options are (in PX):
* gap - gaps between boxes and left/right sides
* topOffset - clearance on the top
* bottomOffset - clearance on the bottom
*/
snapToGrid() {
const windowWidth = window.innerWidth
const windowHeight = window.innerHeight
const gap = 5 //px configurable
const topOffset = 250 //px configurable
const bottomOffset = 60 //px configurable
const elementsInTopRow = Math.ceil(this.viewboxes.length / 2)
const elementsInBottomRow = Math.floor(this.viewboxes.length / 2)
const noOfGapsTop = elementsInTopRow + 1
const noOfGapsBottom = elementsInBottomRow + 1
const viewboxWidthTop = (windowWidth - gap * noOfGapsTop) / elementsInTopRow
const viewboxWidthBottom =
(windowWidth - gap * noOfGapsBottom) / elementsInBottomRow
const viewboxHeight = (windowHeight - topOffset - bottomOffset) / 2
let x = 0
let y = topOffset
let height = viewboxHeight
let viewbox_i = 0
let row_i = 0
for (let i = 0; i < this.viewboxes.length; i++) {
let viewbox = this.viewboxes[i]
let topRow = !(i > elementsInTopRow - 1)
const width = topRow ? viewboxWidthTop : viewboxWidthBottom
if (!topRow && row_i === 0) {
viewbox_i = 0
row_i++
x = 0
}
viewbox.x = gap + x + viewbox_i * (width + gap)
viewbox.y = y + row_i * (height + gap)
viewbox.width = width
viewbox.height = height
viewbox_i++
}
this.viewboxChanged()
setTimeout(() => {
this.setAllHandleTransform()
// Refresh all tables after snap to grid
this.viewboxes.forEach((viewbox) => {
// Settings will include correct height when accessed
this.refreshTableAfterResize(viewbox)
})
})
}
minimizeAll() {
this.viewboxes.forEach((viewbox: Viewbox) => {
viewbox.minimized = true
})
this.viewboxChanged()
}
restoreAll() {
this.viewboxes.forEach((viewbox: Viewbox) => {
viewbox.minimized = false
})
this.viewboxChanged()
}
/**
* Resets Viewbox to default position (top right corner)
* @param viewbox
*/
resetPosSize(viewbox: Viewbox) {
viewbox.x = window.innerWidth - this.defaultConfig.width
viewbox.y = this.defaultConfig.y
viewbox.width = this.defaultConfig.width
viewbox.height = this.defaultConfig.height
this.viewboxChanged()
}
minimize(viewbox: Viewbox) {
viewbox.minimized = true
this.viewboxChanged()
}
restore(viewbox: Viewbox) {
viewbox.minimized = false
this.viewboxChanged()
// Refresh table after restoring
setTimeout(() => {
// Settings will include correct height when accessed
this.refreshTableAfterResize(viewbox)
}, 100)
}
collapse(viewbox: Viewbox) {
viewbox.collapsed = true
this.viewboxChanged()
}
expand(viewbox: Viewbox) {
viewbox.collapsed = false
this.viewboxChanged()
// Refresh table after expanding
setTimeout(() => {
// Settings will include correct height when accessed
this.refreshTableAfterResize(viewbox)
}, 100)
}
/**
* Close Viewbox and remove it's data stored in `paired ViewboxTable array`
* @param viewbox
*/
close(viewbox: Viewbox) {
const index = this.viewboxes.findIndex((vb) => vb.id === viewbox.id)
const viewtableIndex = this.viewboxTables.findIndex(
(vbt) => vbt.viewboxId === viewbox.id
)
if (index > -1) this.viewboxes.splice(index, 1)
if (viewtableIndex > -1) this.viewboxTables.splice(viewtableIndex, 1)
// Clean up settings for this viewbox
this.viewboxHotSettings.delete(viewbox.id)
if (this.selectedViewbox?.id === viewbox.id) {
this.unsetSelectedViewbox()
}
globals.viewboxes[viewbox.id] = this.helperService.deepClone(initFilter)
this.viewboxChanged()
}
/**
* Selects Viewbox in the Viewbox Manager for the columns to be configurated
* @param viewbox
*/
selectViewbox(viewbox: Viewbox) {
if (
this.selectedViewboxTable === undefined &&
this.selectedViewbox === undefined
) {
this.resetSelectedViewbox(viewbox)
} else {
if (viewbox.id === this.selectedViewbox?.id) {
this.unsetSelectedViewbox()
} else {
this.resetSelectedViewbox(viewbox)
}
}
}
/**
* Column drop called after column dragged and dropped
* for Viewbox column to be configurated
* @param event
* @returns
*/
columnsDrop(event: CdkDragDrop<string[]>) {
if (!this.selectedViewboxTable?.hotTable.colHeadersHidden) return
if (event.previousContainer === event.container) {
moveItemInArray(
event.container.data,
event.previousIndex,
event.currentIndex
)
} else {
transferArrayItem(
event.previousContainer.data,
event.container.data,
event.previousIndex,
event.currentIndex
)
}
if (
this.selectedViewboxTable.hotTable &&
typeof this.selectedViewboxTable.hotTable.colHeaders === 'object'
) {
const colProp = event.item.data
const finalIndex = event.currentIndex
this.updateColumnOrderHot(
colProp,
finalIndex,
this.selectedViewboxTable.viewboxId
)
}
}
/**
* Viewbox Manager searching for columns to be added
* @param inputRef Input element
* @param value
* @param columns
*/
onColsearchChange(
inputRef: AutocompleteComponent,
value: string,
columns: string[]
) {
const index = columns.indexOf(value)
columns.splice(index, 1)
inputRef.value = ''
if (this.selectedViewboxTable?.hotTable) {
this.selectedViewboxTable.hotTable.colHeadersVisible.push(value)
this.updateHotColumns(
this.selectedViewboxTable?.hotTable.colHeadersHidden,
this.selectedViewboxTable.viewboxId
)
this.updateColumnOrderHot(
value,
this.selectedViewboxTable.hotTable.colHeadersVisible.length - 1,
this.selectedViewboxTable.viewboxId
)
}
}
/**
* Viewbox Manager removing the column
* @param column
*/
onColRemove(column: string) {
if (this.selectedViewboxTable?.hotTable) {
const index =
this.selectedViewboxTable.hotTable.colHeadersVisible.indexOf(column)
this.selectedViewboxTable.hotTable.colHeadersVisible.splice(index, 1)
this.selectedViewboxTable.hotTable.colHeadersHidden.push(column)
this.updateHotColumns(
this.selectedViewboxTable?.hotTable.colHeadersHidden,
this.selectedViewboxTable.viewboxId
)
this.updateColumnOrderHot(
column,
this.selectedViewboxTable.hotTable.colHeadersVisible.length,
this.selectedViewboxTable.viewboxId
)
}
}
/**
* Re setting the viewbox from parameter to selected viewbox
* And sets the correct data for that viewbox
* @param viewbox
*/
resetSelectedViewbox(viewbox: Viewbox) {
this.selectedViewbox = viewbox
this.selectedViewboxTable = this.viewboxTables.find(
(vbt) => vbt.viewboxId === viewbox.id
)
}
unsetSelectedViewbox() {
this.selectedViewbox = undefined
this.selectedViewboxTable = undefined
}
stopPropagation(event: any) {
event.stopPropagation()
}
/**
* Opens filter dialog for given Viewbox
* @param viewbox
*/
openFilter(viewbox: Viewbox) {
this.selectViewbox(viewbox)
const viewboxTable = this.viewboxTables[this.getViewboxTableIndex(viewbox)]
this.filterLibds = `${viewbox.library}.${viewbox.table}`
this.filteringViewbox = viewbox
this.filter = true
this.cdf.detectChanges()
this.sasStoreService.setQueryVariables(
this.filterLibds,
viewboxTable.hotTable.cols
)
}
/**
* Opens table edit in new tab
* @param viewbox
*/
openTableEdit(viewbox: Viewbox) {
const libDataset = viewbox.library + '.' + viewbox.table
let url = location.href.slice(0, location.href.indexOf('#'))
url = `${url}#/editor/${libDataset}`
window.open(url, '_blank')
}
/**
* Resets the data in filter dialog
* It will also reset the filtering in the given Viewbox
*/
resetFilter() {
if (this.filteringViewbox) {
this.filteringViewbox.filter_pk = '0'
this.reloadTableData(this.filteringViewbox)
this.filter = false
this.viewboxChanged()
globals.viewboxes[this.filteringViewbox.id] =
this.helperService.deepClone(initFilter)
}
}
/**
* Sending filtering request for given Viewbox
*/
sendClause() {
this.filterLoading = true
let nullVariableArr = []
let emptyVariablesArr = []
// to check number of empty clauses
if (typeof this.clauses === 'undefined') {
this.nullVariables = true
this.filterLoading = false
return
} else {
let query = this.clauses.queryObj
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.filterLoading = false
return
} else {
try {
if (this.clauses !== undefined && this.filterLibds) {
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)
this.sasStoreService
.saveQuery(this.filterLibds, filterQueryClauseTable)
.then((res: any) => {
const id = res.result[0].FILTER_RK
const table = res.result[0].FILTER_TABLE
this.filteringViewbox!.filter_pk = id
this.loadData(this.filterLibds!, this.filteringViewbox!).then(
() => {
this.filter = false
this.filterLoading = false
}
)
this.viewboxChanged()
})
.catch((err: any) => {
this.filterLoading = false
})
}
} catch (error: any) {
this.filterLoading = false
}
}
}
async searchTable(inputElement: any, viewbox: Viewbox) {
viewbox.searchLoading = true
let searchValue = inputElement.value
let libDataset = viewbox.library + '.' + viewbox.table
let filter_pk = parseInt(viewbox.filter_pk || '0')
const viewboxTable = this.viewboxTables.find(
(vbt) => vbt.viewboxId === viewbox.id
)
if (!viewboxTable) return
await this.sasStoreService
.viewDataSearch(searchValue, viewbox.searchNumeric, libDataset, filter_pk)
.then((res: any) => {
if (!res.sasparams && !res.viewData) {
viewbox.searchLoading = true
return
}
viewboxTable.hotTable.data = res.viewdata
// Update settings with new data
this.createViewboxTableSettings(viewbox)
})
.catch((err: any) => {
this.loggerService.error(err)
})
viewbox.searchLoading = false
}
async reloadTableData(viewbox: Viewbox) {
const libDataset = `${viewbox.library}.${viewbox.table}`
viewbox.loadingData = true
await this.reloadData(libDataset, viewbox)
viewbox.loadingData = false
this.eventService.dispatchEvent('resize') //Force HOT refresh
}
/**
* Updates the HOT columns Order and Visibility
* @param hiddenColProps Array of indexes of columns to be hidden
* @param viewboxId Viewbox ID for which to apply
*/
private updateHotColumns(hiddenColProps: string[], viewboxId: number) {
this.updateHiddenColumnsHot(hiddenColProps, viewboxId)
this.setColumnOrder(viewboxId)
// Settings will be regenerated when accessed
}
/**
* HOT Columns ordering
* @param colProp Column name
* @param finalIndex Index of where to position it
* @param viewboxId
*/
private updateColumnOrderHot(
colProp: string,
finalIndex: number,
viewboxId: number
) {
const hotInstance = this.getViewboxHotInstance(viewboxId)
if (hotInstance) {
const column = hotInstance.propToCol(colProp) as number
const plugin = hotInstance.getPlugin('manualColumnMove')
plugin.moveColumn(column, finalIndex)
hotInstance.render()
this.setColumnOrder(viewboxId)
}
}
public tableEditExists(viewbox: Viewbox) {
const editTables = globals.editor.libsAndTables
const library = viewbox.library
const table = viewbox.table
// If this line is undefined, that means startupservice failed or similar.
if (!editTables[library]) return false
return editTables[library].includes(table)
}
private setColumnOrder(viewboxId: number) {
const viewbox = this.viewboxes.find((vb) => vb.id === viewboxId)
if (viewbox) {
const columnsOrder = this.createColumnOrder(viewboxId)
viewbox.columns = columnsOrder.length > 0 ? columnsOrder : viewbox.columns
}
this.viewboxChanged()
}
/**
* Creating column order in such format to be encoded in URL
* @param viewboxId
*/
private createColumnOrder(viewboxId: number): number[] {
const hotInstance = this.getViewboxHotInstance(viewboxId)
if (!hotInstance) return []
const hotCols = hotInstance.getColHeader() as string[]
const sasCols = this.selectedViewboxTable?.hotTable.colHeaders as string[]
if (!sasCols) return []
const columnsVisibleLength: number =
this.selectedViewboxTable?.hotTable?.colHeadersVisible.length || 5 //if unexpected happens limit will Be 5 columns
const columnOrder: number[] = []
hotCols.map((hotCol: string, index: number) => {
if (index < columnsVisibleLength) {
const indexofSasCol = sasCols.indexOf(hotCol)
if (indexofSasCol > -1) columnOrder.push(indexofSasCol)
}
})
return columnOrder
}
private updateHiddenColumnsHot(colProps: string[], viewboxId: number) {
const hotInstance = this.getViewboxHotInstance(viewboxId)
if (hotInstance) {
const columns = colProps.map((prop: string) => {
return hotInstance.propToCol(prop) as number
})
hotInstance.updateSettings({
hiddenColumns: {
columns: columns
}
})
hotInstance.render()
}
}
/**
* Calculate available height for Handsontable
* @param viewbox The viewbox to calculate height for
* @returns Available height in pixels
*/
calculateTableHeight(viewbox: Viewbox): number {
// Calculate the exact height of the content div
const dragHandleHeight = 20
const searchFormHeight = 36
const padding = 2
// Return the exact remaining height for the table with minimum height
const calculatedHeight =
viewbox.height - dragHandleHeight - searchFormHeight - padding
return calculatedHeight
}
/**
* Refresh Handsontable instance after resize
* @param viewbox The viewbox to refresh
*/
refreshTableAfterResize(viewbox: Viewbox): void {
const hotInstance = this.getViewboxHotInstance(viewbox.id)
if (hotInstance) {
// Force the table to recalculate its dimensions
setTimeout(() => {
try {
// Update height setting and refresh
hotInstance.updateSettings({
height: this.calculateTableHeight(viewbox)
})
hotInstance.refreshDimensions()
hotInstance.render()
} catch (error) {
// If refresh fails, try again later
setTimeout(() => {
try {
hotInstance.updateSettings({
height: this.calculateTableHeight(viewbox)
})
hotInstance.refreshDimensions()
} catch (e) {
console.warn(
'Failed to refresh HOT dimensions for viewbox',
viewbox.id,
e
)
}
}, 500)
}
}, 100)
}
}
/**
*
* @param viewboxId
* @returns HOT Instance from the given Viewbox
*/
private getViewboxHotInstance(viewboxId?: number): Handsontable | undefined {
if (!viewboxId || !this.hotTableComponents) return
// Find the component corresponding to this viewbox
// Since we only show one table per viewbox and they're rendered in order,
// we can match by the viewbox's position in the array
const viewboxIndex = this.viewboxes.findIndex((vb) => vb.id === viewboxId)
if (viewboxIndex === -1) return
// Get the HOT component at this index
const hotComponents = this.hotTableComponents.toArray()
let hotComponentIndex = 0
// Count how many viewboxes before this one have loaded tables
for (let i = 0; i < viewboxIndex; i++) {
if (this.getViewboxTableIndex(this.viewboxes[i]) > -1) {
hotComponentIndex++
}
}
const hotTableComponent = hotComponents[hotComponentIndex]
return hotTableComponent?.hotInstance || undefined
}
/**
* Called after any Viewbox change that needs to be stored to URL
* It does the data encoding and storing to URL
*/
private viewboxChanged() {
let queryParams: any
const urlData = this.encodeUrlData(this.viewboxes)
if (urlData.length > 0) {
queryParams = {
viewboxes: urlData
}
}
this.router.navigate([], {
relativeTo: this.activatedRoute,
queryParams
})
this.prepareFilterCache()
}
/**
* Prepare the init values to `globals` for the filtering
* that will be used in caching values later
*/
private prepareFilterCache() {
for (let viewbox of this.viewboxes) {
if (!globals.viewboxes[viewbox.id])
globals.viewboxes[viewbox.id] = this.helperService.deepClone(initFilter)
if (viewbox.query && viewbox.query.length > 0) {
const viewboxTable = this.viewboxTables.find(
(vbt) => vbt.viewboxId === viewbox.id
)
const globalsPath = `viewboxes.${viewbox.id}`
globals.viewboxes[viewbox.id].filter.query = viewbox.query
globals.viewboxes[viewbox.id].filter.libds =
viewbox.library + '.' + viewbox.table
this.sasStoreService.initializeGlobalFilterClause(
globalsPath,
viewboxTable?.hotTable.cols
)
}
}
}
/**
* It will encode/inject viewboxes data from URL
* Url data pattern:
*
* {id}-{library}-{table}-{width}-{height}-{x}-{y}-{collapsed}-{minimized}-{filter_pk}-{columns}
* 1-library-table-100-100-10-10-1-1;library2-table2-100-100-10-10-0-0-3-0123456
*
* ; <- single viewbox separation symbol
*/
private encodeUrlData(viewboxes: Viewbox[]): string {
let urlData: string = ''
viewboxes.map((viewbox: Viewbox, index: number) => {
urlData += `${viewbox.id}-${viewbox.library}-${viewbox.table}-${
viewbox.width
}-${viewbox.height}-${viewbox.x}-${viewbox.y}-${
viewbox.collapsed ? 1 : 0
}-${viewbox.minimized ? 1 : 0}-${viewbox.filter_pk || 0}${
viewbox.columns && viewbox.columns.length > 0
? '-' + viewbox.columns?.join(',')
: ''
}`
if (index !== viewboxes.length - 1) urlData += ';'
})
return urlData
}
/**
* It will decode/parse viewboxes data from URL
* Url data pattern:
*
* {id}-{library}-{table}-{width}-{height}-{x}-{y}-{collapsed}-{minimized}-{filter_pk}-{columns}
* 1-library-table-100-100-10-10-1-1;library2-table2-100-100-10-10-0-0-3-0123456
*
* ; <- single viewbox separation symbol
*/
private decodeUrlData(urlData: string): Viewbox[] {
const urlDataMap = {
id: 0,
library: 1,
table: 2,
width: 3,
height: 4,
x: 5,
y: 6,
collapsed: 7,
minimized: 8,
filter_pk: 9,
columns: 10
}
let viewboxes: Viewbox[] = []
const separatedViewboxes = urlData.split(';')
separatedViewboxes.map((viewboxString: string) => {
const viewboxDataArr = viewboxString.split('-')
viewboxes.push({
id: parseInt(viewboxDataArr[urlDataMap.id]),
library: viewboxDataArr[urlDataMap.library],
table: viewboxDataArr[urlDataMap.table],
width: parseInt(viewboxDataArr[urlDataMap.width]),
height: parseInt(viewboxDataArr[urlDataMap.height]),
x: parseInt(viewboxDataArr[urlDataMap.x]),
y: parseInt(viewboxDataArr[urlDataMap.y]),
collapsed: !!parseInt(viewboxDataArr[urlDataMap.collapsed]),
minimized: !!parseInt(viewboxDataArr[urlDataMap.minimized]),
columns:
viewboxDataArr[urlDataMap.columns]
?.split(',')
.map((x) => parseInt(x)) || [],
filter_pk: viewboxDataArr[urlDataMap.filter_pk]
})
})
return viewboxes
}
ngOnDestroy(): void {
this._query?.unsubscribe()
}
}