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 //make query list, handle multiple @ViewChildren('dragHandleCorner') dragHandleCornerQuery!: QueryList @ViewChildren(HotTableComponent) hotTableComponents!: QueryList 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 = new EventEmitter() public libraries!: Array 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 = 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 { 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 ) { 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) { 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() } }