src/app/viewer/viewer.component.ts
AfterContentInit
AfterViewInit
AfterViewChecked
OnDestroy
| encapsulation | ViewEncapsulation.None |
| host | { |
| selector | app-viewer |
| styleUrls | ./viewer.component.scss |
| templateUrl | ./viewer.component.html |
constructor(licenceService: LicenceService, sasStoreService: SasStoreService, sasService: SasService, router: Router, route: ActivatedRoute, eventService: EventService, loggerService: LoggerService, helperService: HelperService, location: Location, cdf: ChangeDetectorRef)
|
|||||||||||||||||||||||||||||||||
|
Defined in src/app/viewer/viewer.component.ts:309
|
|||||||||||||||||||||||||||||||||
|
Parameters :
|
| Private applyDisplayColHeaders |
applyDisplayColHeaders()
|
|
Defined in src/app/viewer/viewer.component.ts:360
|
|
Recomputes
Returns :
void
|
| Public checkExpand | ||||||
checkExpand(event: any)
|
||||||
|
Defined in src/app/viewer/viewer.component.ts:626
|
||||||
|
Parameters :
Returns :
void
|
| Private cleanupHotInstance |
cleanupHotInstance()
|
|
Defined in src/app/viewer/viewer.component.ts:1283
|
|
CRITICAL CLEANUP (workaround needed for HOT version 16 and above): Safely destroys Handsontable instances Purpose: Prevents "instance destroyed" errors and memory leaks during table switching Called from:
Safety features:
Returns :
void
|
| Private clearGlobalsFilter |
clearGlobalsFilter()
|
|
Defined in src/app/viewer/viewer.component.ts:797
|
|
Returns :
void
|
| Public collapseLibraryItems |
collapseLibraryItems(libraries: any, libraryToSkip: any)
|
|
Defined in src/app/viewer/viewer.component.ts:628
|
|
Returns :
void
|
| Private configureHotInstance |
configureHotInstance()
|
|
Defined in src/app/viewer/viewer.component.ts:1345
|
|
Applies settings that can't go through Angular [settings] binding:
Returns :
void
|
| Public copyToClip |
copyToClip()
|
|
Defined in src/app/viewer/viewer.component.ts:614
|
|
Returns :
void
|
| Public datasetInfoModalRowClicked | ||||||
datasetInfoModalRowClicked(value: Version | DSMeta)
|
||||||
|
Defined in src/app/viewer/viewer.component.ts:1244
|
||||||
|
Parameters :
Returns :
void
|
| Public downloadData |
downloadData()
|
|
Defined in src/app/viewer/viewer.component.ts:480
|
|
Downloads file from backend, against
Returns :
void
|
| Public downloadDDL |
downloadDDL()
|
|
Defined in src/app/viewer/viewer.component.ts:517
|
|
Downloads file from backend, against
Returns :
void
|
| Public editTable |
editTable()
|
|
Defined in src/app/viewer/viewer.component.ts:556
|
|
Navigate to the edit page of a viewing table
Returns :
void
|
| Public filterFn | ||||||
filterFn(input: string)
|
||||||
|
Defined in src/app/viewer/viewer.component.ts:469
|
||||||
|
FIXME: Should be removed, not used
Parameters :
Returns :
void
|
| Private fixAriaAccessibility |
fixAriaAccessibility()
|
|
Defined in src/app/viewer/viewer.component.ts:1546
|
|
Fixes ARIA accessibility issues in the Handsontable component This addresses the accessibility report issues with treegrid and presentation roles
Returns :
void
|
| Public goToLineage |
goToLineage()
|
|
Defined in src/app/viewer/viewer.component.ts:579
|
|
Navigate to the lineage of a viewing table
Returns :
void
|
| Public goToViewer |
goToViewer()
|
|
Defined in src/app/viewer/viewer.component.ts:618
|
|
Returns :
void
|
| Public libraryExpandedChange |
libraryExpandedChange(expanded: any, library: any)
|
|
Defined in src/app/viewer/viewer.component.ts:652
|
|
Returns :
void
|
| Public Async libraryOnClick |
libraryOnClick(lib: string, library: any, expandOnly?: boolean)
|
|
Defined in src/app/viewer/viewer.component.ts:658
|
|
Returns :
any
|
| Public libraryOnFilter |
libraryOnFilter()
|
|
Defined in src/app/viewer/viewer.component.ts:820
|
|
Returns :
void
|
| Public libraryResetFilter |
libraryResetFilter()
|
|
Defined in src/app/viewer/viewer.component.ts:830
|
|
Returns :
void
|
| Public libTabActive |
libTabActive(libraryRef: string, table: string)
|
|
Defined in src/app/viewer/viewer.component.ts:806
|
|
Returns :
boolean
|
| Public loadMoreLibraries |
loadMoreLibraries()
|
|
Defined in src/app/viewer/viewer.component.ts:636
|
|
Returns :
void
|
| Async loadWithoutParameters |
loadWithoutParameters()
|
|
Defined in src/app/viewer/viewer.component.ts:1426
|
|
Returns :
any
|
| Async loadWithParameters |
loadWithParameters()
|
|
Defined in src/app/viewer/viewer.component.ts:1401
|
|
Returns :
any
|
| Public maxWidthCheker |
maxWidthCheker(width: any, col: any)
|
|
Defined in src/app/viewer/viewer.component.ts:1146
|
|
Returns :
any
|
| Public newViewbox |
newViewbox()
|
|
Defined in src/app/viewer/viewer.component.ts:375
|
|
Open viewboxes modal
Returns :
void
|
| Async ngAfterContentInit |
ngAfterContentInit()
|
|
Defined in src/app/viewer/viewer.component.ts:1689
|
|
Returns :
any
|
| ngAfterViewChecked |
ngAfterViewChecked()
|
|
Defined in src/app/viewer/viewer.component.ts:1463
|
|
Returns :
void
|
| ngAfterViewInit |
ngAfterViewInit()
|
|
Defined in src/app/viewer/viewer.component.ts:1505
|
|
Returns :
void
|
| ngOnDestroy |
ngOnDestroy()
|
|
Defined in src/app/viewer/viewer.component.ts:1512
|
|
Returns :
void
|
| ngOnInit |
ngOnInit()
|
|
Defined in src/app/viewer/viewer.component.ts:326
|
|
Returns :
void
|
| Public onCliCommandFocus | ||||||||
onCliCommandFocus(evt: any)
|
||||||||
|
Defined in src/app/viewer/viewer.component.ts:548
|
||||||||
|
When clicked on textarea in the Web Query Modal, this function will select all text inside.
Parameters :
Returns :
void
|
| Public onTableClick |
onTableClick(libTable: any, library: any)
|
|
Defined in src/app/viewer/viewer.component.ts:698
|
|
Returns :
void
|
| Public openQb |
openQb()
|
|
Defined in src/app/viewer/viewer.component.ts:1151
|
|
Returns :
void
|
| Public Async reloadLibInfo |
reloadLibInfo()
|
|
Defined in src/app/viewer/viewer.component.ts:446
|
|
By default, SAS will send cached library information. This function calls SAS to update the backend database. It will also show the latest info on the page.
Returns :
any
|
| Public reloadTableData |
reloadTableData()
|
|
Defined in src/app/viewer/viewer.component.ts:437
|
|
Re sending request to backend and re-setting data in the HOT
Returns :
void
|
| Public removeQuery |
removeQuery()
|
|
Defined in src/app/viewer/viewer.component.ts:1240
|
|
Returns :
void
|
| Public resetFilter |
resetFilter()
|
|
Defined in src/app/viewer/viewer.component.ts:382
|
|
Resetting filter variables
Returns :
void
|
| Private scheduleFilterOverflowCheck |
scheduleFilterOverflowCheck()
|
|
Defined in src/app/viewer/viewer.component.ts:1474
|
|
Measures whether the applied-filter clause fits the collapsed single line,
and shows or hides the chevron accordingly. The measurement is deferred
because
Returns :
void
|
| Public Async searchTable | ||||||||
searchTable(inputElement: any)
|
||||||||
|
Defined in src/app/viewer/viewer.component.ts:394
|
||||||||
|
Searching table against particular string, data is comming from backend. There is also a toggle that will search for a numeric values
Parameters :
Returns :
any
|
| Public selectLibTable |
selectLibTable(ev: string, initial?: boolean)
|
|
Defined in src/app/viewer/viewer.component.ts:782
|
|
Returns :
void
|
| Public Async selectTable |
selectTable(lib: string, initial?: boolean, library?: any)
|
|
Defined in src/app/viewer/viewer.component.ts:719
|
|
Returns :
any
|
| Public Async sendClause |
sendClause()
|
|
Defined in src/app/viewer/viewer.component.ts:1159
|
|
Returns :
any
|
| Private setDSNote |
setDSNote()
|
|
Defined in src/app/viewer/viewer.component.ts:1254
|
|
Returns :
void
|
| Private setupHot |
setupHot()
|
|
Defined in src/app/viewer/viewer.component.ts:1305
|
|
PERFORMANCE: Configures Handsontable with enhanced error handling (workaround needed for HOT version 16 and above)
Instance lifecycle is managed by Angular's hot-table component via [data] and [settings] bindings. This method only applies additional config that can't go through bindings (hooks, PK styling).
Returns :
void
|
| Public showTableSelect |
showTableSelect()
|
|
Defined in src/app/viewer/viewer.component.ts:622
|
|
Returns :
void
|
| Public showWebQuery |
showWebQuery()
|
|
Defined in src/app/viewer/viewer.component.ts:588
|
|
Displays web query modal
Returns :
void
|
| Public tableEditExists |
tableEditExists()
|
|
Defined in src/app/viewer/viewer.component.ts:564
|
|
Used to show/hide the edit table button
Returns :
any
Wheter currently viewed table is edtiable |
| Public toggleFilterPanel |
toggleFilterPanel()
|
|
Defined in src/app/viewer/viewer.component.ts:1501
|
|
Returns :
void
|
| Public treeNodeClicked |
treeNodeClicked(event: any, library: any)
|
|
Defined in src/app/viewer/viewer.component.ts:646
|
|
Returns :
void
|
| Public treeOnFilter |
treeOnFilter(array: any, arrToFilter: string)
|
|
Defined in src/app/viewer/viewer.component.ts:816
|
|
Returns :
void
|
| Private updateHotTableSettings |
updateHotTableSettings()
|
|
Defined in src/app/viewer/viewer.component.ts:138
|
|
Returns :
void
|
| Public Async viewData | ||||||
viewData(filter_pk: number)
|
||||||
|
Defined in src/app/viewer/viewer.component.ts:834
|
||||||
|
Parameters :
Returns :
any
|
| Private _query |
Type : Subscription
|
|
Defined in src/app/viewer/viewer.component.ts:296
|
| Private _queryParams |
Type : Subscription
|
|
Defined in src/app/viewer/viewer.component.ts:297
|
| Public $dataFormats |
Type : $DataFormats | null
|
Default value : null
|
|
Defined in src/app/viewer/viewer.component.ts:166
|
| Public abortActive |
Type : boolean
|
Default value : false
|
|
Defined in src/app/viewer/viewer.component.ts:92
|
| Public actionDisable |
Type : boolean
|
Default value : false
|
|
Defined in src/app/viewer/viewer.component.ts:125
|
| Private ariaCheckInterval |
Type : any | undefined
|
|
Defined in src/app/viewer/viewer.component.ts:178
|
| Private ariaObserver |
Type : MutationObserver | undefined
|
|
Defined in src/app/viewer/viewer.component.ts:177
|
| Public clauses |
Type : any
|
|
Defined in src/app/viewer/viewer.component.ts:96
|
| Public colNames |
Type : string[]
|
Default value : []
|
|
Defined in src/app/viewer/viewer.component.ts:87
|
| Public datasetInfo |
Type : boolean
|
Default value : false
|
|
Defined in src/app/viewer/viewer.component.ts:169
|
| Public downloadFormat |
Type : string
|
Default value : 'CSV'
|
|
Defined in src/app/viewer/viewer.component.ts:129
|
| Public dsmeta |
Type : DSMeta[]
|
Default value : []
|
|
Defined in src/app/viewer/viewer.component.ts:170
|
| Public dsNote |
Type : string
|
Default value : ''
|
|
Defined in src/app/viewer/viewer.component.ts:172
|
| Public filter |
Type : boolean
|
Default value : false
|
|
Defined in src/app/viewer/viewer.component.ts:82
|
| Public filterCols |
Type : Col[]
|
Default value : []
|
|
Defined in src/app/viewer/viewer.component.ts:83
|
| Public filterExpanded |
Type : unknown
|
Default value : false
|
|
Defined in src/app/viewer/viewer.component.ts:108
|
|
The applied-filter panel is collapsed to a single line by default. The chevron that expands it is only useful when the clause does not fit that line, which depends on the rendered width - so it is measured from the DOM rather than guessed from the length of the text. |
| Private filterMeasuredKey |
Type : string
|
Default value : ''
|
|
Defined in src/app/viewer/viewer.component.ts:113
|
| Public filterOverflows |
Type : unknown
|
Default value : false
|
|
Defined in src/app/viewer/viewer.component.ts:109
|
| Public headerPks |
Type : string[]
|
Default value : []
|
|
Defined in src/app/viewer/viewer.component.ts:165
|
| Public hiddenViewColumns |
Type : number[]
|
Default value : []
|
|
Defined in src/app/viewer/viewer.component.ts:168
|
| Private hooksAttached |
Type : unknown
|
Default value : false
|
|
Defined in src/app/viewer/viewer.component.ts:1337
|
| Private hotInstance |
Type : Handsontable | null
|
Default value : null
|
|
Defined in src/app/viewer/viewer.component.ts:299
|
| Public hotInstanceClickListener |
Type : boolean
|
Default value : false
|
|
Defined in src/app/viewer/viewer.component.ts:300
|
| hotInstanceViewChild |
Type : Handsontable
|
Decorators :
@ViewChild('hotInstance', {static: false})
|
|
Defined in src/app/viewer/viewer.component.ts:69
|
| Public hotTable |
Type : HotTableInterface
|
Default value : {
data: [],
colHeaders: [],
columns: [],
height: 'calc(100vh - 182px)',
maxRows: this.licenceState.value.viewer_rows_allowed || Infinity,
settings: {},
licenseKey: undefined,
rowHeaders: (index: number) => {
return ' '
},
afterGetColHeader: (col: number, th: any, headerLevel: number) => {
// CRITICAL: Prevent "colToProp method cannot be called because this Handsontable instance has been destroyed" error
// This callback can be triggered even after the instance is destroyed during rapid table switching
if (!this.hotInstance || this.hotInstance.isDestroyed) {
// Graceful fallback: apply only dark mode styling when instance is unavailable
th.classList.add(globals.handsontable.darkTableHeaderClass)
return
}
try {
const column = this.hotInstance.colToProp(col) as string
const isPKCol = column && this.headerPks.indexOf(column) > -1
if (isPKCol) th.classList.add('primaryKeyHeaderStyle')
// Apply dark mode styling to all headers
th.classList.add(globals.handsontable.darkTableHeaderClass)
} catch (error) {
// Safety net: if colToProp() fails, still apply basic styling
th.classList.add(globals.handsontable.darkTableHeaderClass)
}
},
rowHeaderWidth: 15,
rowHeights: 20,
contextMenu: {
items: {
copy_with_column_headers: {},
copy_column_headers_only: {},
export_file: buildExportMenuItem(() => this.tableTitle || 'export'),
// 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'
})
}
}
}
},
copyPaste: {
copyColumnHeaders: true,
copyColumnHeadersOnly: true
},
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 &&
!this.hotInstance.isDestroyed &&
!this.isTableSwitching
) {
try {
// 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]
} catch (error) {
// Ignore errors during table switching
colInfo = undefined
}
textInfo = buildColInfoHtml(colName, colInfo)
}
elem.innerHTML = textInfo
return elem
}
}
}
}
}
|
|
Defined in src/app/viewer/viewer.component.ts:180
|
| hotTableComponent |
Type : HotTableComponent
|
Decorators :
@ViewChild(HotTableComponent, {static: false})
|
|
Defined in src/app/viewer/viewer.component.ts:134
|
| Public hotTableSettings |
Type : Handsontable.GridSettings
|
Default value : {}
|
|
Defined in src/app/viewer/viewer.component.ts:136
|
| Public Infinity |
Type : unknown
|
Default value : Infinity
|
|
Defined in src/app/viewer/viewer.component.ts:175
|
| Optional infoBar |
Type : ElementRef<HTMLElement>
|
Decorators :
@ViewChild('infoBar')
|
|
Defined in src/app/viewer/viewer.component.ts:111
|
| Private isTableSwitching |
Type : boolean
|
Default value : false
|
|
Defined in src/app/viewer/viewer.component.ts:303
|
| Private lastSetupTime |
Type : number
|
Default value : 0
|
|
Defined in src/app/viewer/viewer.component.ts:307
|
| Public lib |
Type : any
|
|
Defined in src/app/viewer/viewer.component.ts:75
|
| Public libDataset |
Type : any
|
|
Defined in src/app/viewer/viewer.component.ts:117
|
| Public libds |
Type : string
|
|
Defined in src/app/viewer/viewer.component.ts:97
|
| Public libinfo |
Type : Libinfo[] | null
|
Default value : null
|
|
Defined in src/app/viewer/viewer.component.ts:79
|
| Public libraries |
Type : Array<any>
|
|
Defined in src/app/viewer/viewer.component.ts:71
|
| Public librariesLoading |
Type : boolean
|
Default value : false
|
|
Defined in src/app/viewer/viewer.component.ts:119
|
| Public librariesPaging |
Type : boolean
|
Default value : false
|
|
Defined in src/app/viewer/viewer.component.ts:72
|
| Public librariesSearch |
Type : string
|
Default value : ''
|
|
Defined in src/app/viewer/viewer.component.ts:73
|
| Public librariesTreeExpanded |
Type : boolean
|
Default value : false
|
|
Defined in src/app/viewer/viewer.component.ts:76
|
| Public libraryTablesRef |
Type : string
|
Default value : ''
|
|
Defined in src/app/viewer/viewer.component.ts:74
|
| Public libTab |
Type : string
|
|
Defined in src/app/viewer/viewer.component.ts:98
|
| Public licenceState |
Type : unknown
|
Default value : this.licenceService.licenceState
|
|
Defined in src/app/viewer/viewer.component.ts:174
|
| Public loadingTableView |
Type : boolean
|
Default value : false
|
|
Defined in src/app/viewer/viewer.component.ts:120
|
| Public noData |
Type : boolean
|
Default value : false
|
|
Defined in src/app/viewer/viewer.component.ts:122
|
| Public noDataReqErr |
Type : boolean
|
Default value : false
|
|
Defined in src/app/viewer/viewer.component.ts:123
|
| Public nullVariables |
Type : boolean
|
Default value : false
|
|
Defined in src/app/viewer/viewer.component.ts:91
|
| Public numberOfRows |
Type : number | null
|
Default value : null
|
|
Defined in src/app/viewer/viewer.component.ts:164
|
| Public openDownload |
Type : boolean
|
Default value : false
|
|
Defined in src/app/viewer/viewer.component.ts:126
|
| Public queryErr |
Type : boolean
|
Default value : false
|
|
Defined in src/app/viewer/viewer.component.ts:115
|
| Public queryErrMessage |
Type : string
|
|
Defined in src/app/viewer/viewer.component.ts:116
|
| Public queryFilter |
Type : any
|
|
Defined in src/app/viewer/viewer.component.ts:93
|
| queryFilterCompList |
Type : QueryList<QueryComponent>
|
Default value : new QueryList()
|
Decorators :
@ViewChildren('queryFilter')
|
|
Defined in src/app/viewer/viewer.component.ts:66
|
| Public queryText |
Type : string
|
Default value : ''
|
|
Defined in src/app/viewer/viewer.component.ts:99
|
| Public sasjsConfig |
Type : SASjsConfig
|
Default value : new SASjsConfig()
|
|
Defined in src/app/viewer/viewer.component.ts:130
|
| Public searchLoading |
Type : boolean
|
Default value : false
|
|
Defined in src/app/viewer/viewer.component.ts:131
|
| Public searchNumeric |
Type : boolean
|
Default value : false
|
|
Defined in src/app/viewer/viewer.component.ts:132
|
| Public submitLoading |
Type : boolean
|
|
Defined in src/app/viewer/viewer.component.ts:114
|
| Public switchFlag |
Type : boolean
|
Default value : false
|
|
Defined in src/app/viewer/viewer.component.ts:121
|
| Private switchingTimeout |
Type : any
|
Default value : null
|
|
Defined in src/app/viewer/viewer.component.ts:304
|
| Public table |
Type : any
|
|
Defined in src/app/viewer/viewer.component.ts:80
|
| Public tableDisable |
Type : boolean
|
Default value : false
|
|
Defined in src/app/viewer/viewer.component.ts:124
|
| Public tableFlag |
Type : boolean
|
Default value : true
|
|
Defined in src/app/viewer/viewer.component.ts:118
|
| Public tables |
Type : any
|
|
Defined in src/app/viewer/viewer.component.ts:77
|
| Public tableTitle |
Type : any
|
|
Defined in src/app/viewer/viewer.component.ts:78
|
| Public tableuri |
Type : string | null
|
Default value : null
|
|
Defined in src/app/viewer/viewer.component.ts:81
|
| Public urlFilterPk |
Type : number | null
|
Default value : null
|
|
Defined in src/app/viewer/viewer.component.ts:94
|
| Public useLabels |
Type : boolean
|
Default value : false
|
|
Defined in src/app/viewer/viewer.component.ts:90
|
| Public versions |
Type : Version[]
|
Default value : []
|
|
Defined in src/app/viewer/viewer.component.ts:171
|
| Public viewboxOpen |
Type : boolean
|
Default value : false
|
|
Defined in src/app/viewer/viewer.component.ts:309
|
| Public webQuery |
Type : boolean
|
Default value : false
|
|
Defined in src/app/viewer/viewer.component.ts:127
|
| Public webQueryTab |
Type : boolean
|
Default value : false
|
|
Defined in src/app/viewer/viewer.component.ts:128
|
| Public webQueryText |
Type : string
|
Default value : ''
|
|
Defined in src/app/viewer/viewer.component.ts:100
|
| Public whereString |
Type : string
|
|
Defined in src/app/viewer/viewer.component.ts:95
|
import {
Component,
AfterContentInit,
ChangeDetectorRef,
AfterViewInit,
AfterViewChecked,
ElementRef,
OnDestroy,
ViewChildren,
QueryList,
ViewEncapsulation,
ViewChild
} from '@angular/core'
import { SasStoreService } from '../services/sas-store.service'
import { Subscription } from 'rxjs'
import { Router } from '@angular/router'
import { ActivatedRoute } from '@angular/router'
import { globals } from '../_globals'
import { EventService } from '../services/event.service'
import { HelperService } from '../services/helper.service'
import { HotTableComponent } from '@handsontable/angular-wrapper'
import { SasService } from '../services/sas.service'
import { SASjsConfig } from '@sasjs/adapter'
import { QueryComponent } from '../query/query.component'
import { FilterGroup, FilterQuery } from '../models/FilterQuery'
import { HotTableInterface } from '../models/HotTable.interface'
import { buildExportMenuItem } from '../shared/hot-export/hot-export.util'
import { LoggerService } from '../services/logger.service'
import Handsontable from 'handsontable'
import {
$DataFormats,
DSMeta,
Version
} from '../models/sas/editors-getdata.model'
import { mergeColsRules } from '../shared/dc-validator/utils/mergeColsRules'
import { makeNumberFormatRenderer } from '../editor/utils/renderers.utils'
import { PublicViewtablesServiceResponse } from '../models/sas/public-viewtables.model'
import { PublicViewlibsServiceResponse } from '../models/sas/public-viewlibs.model'
import { PublicRefreshlibinfoServiceResponse } from '../models/sas/public-refreshlibinfo.model'
import { DataFormat } from '../models/sas/common/DateFormat'
import { Libinfo } from '../models/sas/common/Libinfo'
import { LicenceService } from '../services/licence.service'
import { Location } from '@angular/common'
import { Col } from '../shared/dc-validator/models/col.model'
import { parseLabelsParam } from '../shared/utils/parse-labels-param'
import { getDisplayColHeaders } from '../shared/utils/display-col-headers'
import { buildColInfoHtml } from '../shared/utils/col-info-html'
@Component({
selector: 'app-viewer',
templateUrl: './viewer.component.html',
styleUrls: ['./viewer.component.scss'],
host: {
class: 'content-container'
},
encapsulation: ViewEncapsulation.None,
standalone: false
})
export class ViewerComponent
implements AfterContentInit, AfterViewInit, AfterViewChecked, OnDestroy
{
@ViewChildren('queryFilter')
queryFilterCompList: QueryList<QueryComponent> = new QueryList()
@ViewChild('hotInstance', { static: false })
hotInstanceViewChild!: Handsontable
public libraries!: Array<any>
public librariesPaging: boolean = false
public librariesSearch: string = ''
public libraryTablesRef: string = ''
public lib: any
public librariesTreeExpanded: boolean = false
public tables: any
public tableTitle: any
public libinfo: Libinfo[] | null = null
public table: any
public tableuri: string | null = null
public filter: boolean = false
public filterCols: Col[] = []
// NAMEs of the currently loaded columns, in grid order — the canonical
// source `getDisplayColHeaders` maps against; `hotTable.colHeaders` holds
// whichever of NAME/LABEL is currently displayed.
public colNames: string[] = []
// `?labels=true` toggle (see parseLabelsParam) — URL is the source of
// truth, kept in sync via the queryParams subscription below.
public useLabels: boolean = false
public nullVariables: boolean = false
public abortActive: boolean = false
public queryFilter: any
public urlFilterPk: number | null = null
public whereString!: string
public clauses: any
public libds!: string
public libTab!: string
public queryText: string = ''
public webQueryText: string = ''
/**
* The applied-filter panel is collapsed to a single line by default. The
* chevron that expands it is only useful when the clause does not fit that
* line, which depends on the rendered width - so it is measured from the DOM
* rather than guessed from the length of the text.
*/
public filterExpanded = false
public filterOverflows = false
@ViewChild('infoBar') infoBar?: ElementRef<HTMLElement>
private filterMeasuredKey = ''
public submitLoading!: boolean
public queryErr: boolean = false
public queryErrMessage!: string
public libDataset: any
public tableFlag: boolean = true
public librariesLoading: boolean = false
public loadingTableView: boolean = false
public switchFlag: boolean = false
public noData: boolean = false
public noDataReqErr: boolean = false
public tableDisable: boolean = false
public actionDisable: boolean = false
public openDownload: boolean = false
public webQuery: boolean = false
public webQueryTab: boolean = false
public downloadFormat: string = 'CSV'
public sasjsConfig: SASjsConfig = new SASjsConfig()
public searchLoading: boolean = false
public searchNumeric: boolean = false
@ViewChild(HotTableComponent, { static: false })
hotTableComponent!: HotTableComponent
public hotTableSettings: Handsontable.GridSettings = {}
private updateHotTableSettings(): void {
this.hotTableSettings = {
multiColumnSorting: true,
viewportRowRenderingOffset: 30,
colHeaders: this.hotTable.colHeaders,
columns: this.hotTable.columns,
copyPaste: this.hotTable.copyPaste,
contextMenu: this.hotTable.contextMenu,
filters: true,
dropdownMenu: this.hotTable.dropdownMenu,
height: this.hotTable.height,
stretchH: 'all',
modifyColWidth: this.maxWidthCheker,
cells: this.hotTable.cells,
hiddenColumns: { columns: this.hiddenViewColumns, indicators: false },
maxRows: this.hotTable.maxRows,
manualColumnResize: true,
afterGetColHeader: this.hotTable.afterGetColHeader,
rowHeaders: this.hotTable.rowHeaders,
rowHeaderWidth: this.hotTable.rowHeaderWidth,
rowHeights: this.hotTable.rowHeights,
licenseKey: this.hotTable.licenseKey,
className: 'htDark',
theme: 'ht-theme-classic'
}
}
public numberOfRows: number | null = null
public headerPks: string[] = []
public $dataFormats: $DataFormats | null = null
// Physical column indexes to hide in the viewer (from HIDDEN DQ rules)
public hiddenViewColumns: number[] = []
public datasetInfo: boolean = false
public dsmeta: DSMeta[] = []
public versions: Version[] = []
public dsNote = ''
public licenceState = this.licenceService.licenceState
public Infinity = Infinity
private ariaObserver: MutationObserver | undefined
private ariaCheckInterval: any | undefined
public hotTable: HotTableInterface = {
data: [],
colHeaders: [],
columns: [],
height: 'calc(100vh - 182px)',
maxRows: this.licenceState.value.viewer_rows_allowed || Infinity,
settings: {},
licenseKey: undefined,
rowHeaders: (index: number) => {
return ' '
},
afterGetColHeader: (col: number, th: any, headerLevel: number) => {
// CRITICAL: Prevent "colToProp method cannot be called because this Handsontable instance has been destroyed" error
// This callback can be triggered even after the instance is destroyed during rapid table switching
if (!this.hotInstance || this.hotInstance.isDestroyed) {
// Graceful fallback: apply only dark mode styling when instance is unavailable
th.classList.add(globals.handsontable.darkTableHeaderClass)
return
}
try {
const column = this.hotInstance.colToProp(col) as string
const isPKCol = column && this.headerPks.indexOf(column) > -1
if (isPKCol) th.classList.add('primaryKeyHeaderStyle')
// Apply dark mode styling to all headers
th.classList.add(globals.handsontable.darkTableHeaderClass)
} catch (error) {
// Safety net: if colToProp() fails, still apply basic styling
th.classList.add(globals.handsontable.darkTableHeaderClass)
}
},
rowHeaderWidth: 15,
rowHeights: 20,
contextMenu: {
items: {
copy_with_column_headers: {},
copy_column_headers_only: {},
export_file: buildExportMenuItem(() => this.tableTitle || 'export'),
// 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'
})
}
}
}
},
copyPaste: {
copyColumnHeaders: true,
copyColumnHeadersOnly: true
},
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 &&
!this.hotInstance.isDestroyed &&
!this.isTableSwitching
) {
try {
// 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]
} catch (error) {
// Ignore errors during table switching
colInfo = undefined
}
textInfo = buildColInfoHtml(colName, colInfo)
}
elem.innerHTML = textInfo
return elem
}
}
}
}
}
private _query!: Subscription
private _queryParams!: Subscription
private hotInstance: Handsontable | null = null
public hotInstanceClickListener: boolean = false
// Race condition prevention for rapid table switching
private isTableSwitching: boolean = false
private switchingTimeout: any = null
// Prevents duplicate setupHot() calls within short time windows
private lastSetupTime: number = 0
public viewboxOpen: boolean = false
constructor(
private licenceService: LicenceService,
private sasStoreService: SasStoreService,
private sasService: SasService,
private router: Router,
private route: ActivatedRoute,
private eventService: EventService,
private loggerService: LoggerService,
private helperService: HelperService,
private location: Location,
private cdf: ChangeDetectorRef
) {
this.sasjsConfig = this.sasService.getSasjsConfig()
}
ngOnInit() {
if (
globals.viewer.currentSelection !== '' &&
Object.keys(this.route.snapshot.params).length === 0
) {
this.router.navigate([globals.viewer.currentSelection], {
queryParamsHandling: 'preserve'
})
}
if (this.route.snapshot.params['libMem'] !== undefined) {
globals.viewer.currentSelection = 'view/data'
}
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()
})
}
/**
* Recomputes `hotTable.colHeaders` from `colNames`/`filterCols` for the
* current `useLabels` state, and pushes it into a live grid via
* `updateSettings` if one is mounted (no refetch needed).
*/
private applyDisplayColHeaders() {
this.hotTable.colHeaders = getDisplayColHeaders(
this.colNames,
this.filterCols,
this.useLabels
)
if (this.hotInstance && !this.hotInstance.isDestroyed) {
this.hotInstance.updateSettings({ colHeaders: this.hotTable.colHeaders })
}
}
/**
* Open viewboxes modal
*/
public newViewbox() {
this.viewboxOpen = true
}
/**
* Resetting filter variables
*/
public resetFilter() {
if (this.queryFilterCompList.first) {
this.queryFilterCompList.first.resetFilter()
}
}
/**
* Searching table against particular string, data is comming from backend.
* There is also a toggle that will search for a numeric values
*
* @param inputElement input from which search string is captured
*/
public async searchTable(inputElement: any) {
this.searchLoading = true
let searchValue = inputElement.value
let libDataset = this.lib + '.' + this.table
let filter_pk = parseInt(this.route.snapshot.params['filterId']) || 0
await this.sasStoreService
.viewDataSearch(searchValue, this.searchNumeric, libDataset, filter_pk)
.then((res: any) => {
if (!res.sasparams && !res.viewData) {
this.searchLoading = false
return
}
this.hotTable.data = res.viewdata
this.$dataFormats = res.$viewdata
this.dsmeta = res.dsmeta
this.versions = res.versions || []
this.setDSNote()
this.numberOfRows = res.sasparams[0].NOBS
this.queryText = res.sasparams[0].FILTER_TEXT
this.headerPks = res.sasparams[0].PK_FIELDS.split(' ')
if (this.hotTable.data.length === 0) {
this.noData = true
} else {
this.noData = false
this.tableFlag = false
this.setupHot()
}
})
.catch((err: any) => {
this.loggerService.error(err)
})
this.searchLoading = false
}
/**
* Re sending request to backend and re-setting data in the HOT
*/
public reloadTableData() {
this.viewData(this.urlFilterPk || 0)
}
/**
* By default, SAS will send cached library information.
* This function calls SAS to update the backend database.
* It will also show the latest info on the page.
*/
public async reloadLibInfo() {
this.libinfo = null
this.sasStoreService.refreshLibInfo(this.lib).then(
async (res: PublicRefreshlibinfoServiceResponse) => {
this.libinfo = res.libinfo
globals.viewer.libinfo = this.libinfo
const library = this.libraries.find((x) => x.LIBRARYREF === this.lib)
if (library) library.libinfo = this.libinfo
},
(err: any) => {
this.loggerService.error(err)
if (this.libinfo === null) this.libinfo = []
}
)
}
/**
* FIXME: Should be removed, not used
*/
public filterFn(input: string) {
let libraries = this.libraries
this.libraries = libraries.filter(
(item) =>
item.LIBRARYNAME.toLowerCase().indexOf(input.toLocaleLowerCase()) !== -1
)
}
/**
* Downloads file from backend, against `getrawdata` service, link is created and open in new tab
*/
public downloadData() {
let storage = this.sasjsConfig.serverUrl
let metaData = this.sasjsConfig.appLoc
const path = this.sasService.getExecutionPath()
let type = '&type=' + this.downloadFormat
let table = '&table=' + this.tableTitle
let contextname =
this.sasjsConfig.serverType === 'SASVIYA'
? `&_contextname=${this.sasjsConfig.contextName}`
: ''
let filter_pk: number
if (typeof this.route.snapshot.params['filterId'] === 'undefined') {
filter_pk = 0
} else {
filter_pk = parseInt(this.route.snapshot.params['filterId'])
}
let filter = '&filter=' + filter_pk
let downUrl =
storage +
path +
'/?_program=' +
metaData +
'/services/public/getrawdata' +
type +
table +
contextname +
filter
window.open(downUrl)
this.openDownload = false
}
/**
* Downloads file from backend, against `getddl` service, link is created and open in new tab
*/
public downloadDDL() {
let libref = this.lib
let ds = this.table
let flavour = this.downloadFormat.replace('_DDL', '')
let storage = this.sasjsConfig.serverUrl
let metaData = this.sasjsConfig.appLoc
const path = this.sasService.getExecutionPath()
let contextname =
this.sasjsConfig.serverType === 'SASVIYA'
? `&_contextname=${this.sasjsConfig.contextName}`
: ''
let params = `&ds=${ds}&libref=${libref}&flavour=${flavour}${contextname}`
let downUrl =
storage +
path +
'/?_program=' +
metaData +
'/services/public/getddl' +
params
window.open(downUrl)
this.openDownload = false
}
/**
* When clicked on textarea in the Web Query Modal, this function will
* select all text inside.
* @param evt textarea which contains the web query text
*/
public onCliCommandFocus(evt: any): void {
evt.preventDefault()
evt.target.select()
}
/**
* Navigate to the edit page of a viewing table
*/
public editTable() {
this.router.navigateByUrl('/editor/' + this.libTab)
}
/**
* Used to show/hide the edit table button
* @returns Wheter currently viewed table is edtiable
*/
public tableEditExists() {
let editTables: any = {}
editTables = globals.editor.libsAndTables
let currentTable = this.libTab.split('.')[1]
let currentLibrary = this.libTab.split('.')[0]
// If this line is undefined, that means startupservice failed or similar.
if (!editTables[currentLibrary]) return false
return editTables[currentLibrary].includes(currentTable)
}
/**
* Navigate to the lineage of a viewing table
*/
public goToLineage() {
let routeUri = this.tableuri!.split('\\')[1]
let lineageUrl = `/view/lineage/${routeUri}/REVERSE`
this.router.navigateByUrl(lineageUrl)
}
/**
* Displays web query modal
*/
public showWebQuery() {
this.webQuery = true
let filter_pk: number
if (typeof this.route.snapshot.params['filterId'] === 'undefined') {
filter_pk = 0
} else {
filter_pk = parseInt(this.route.snapshot.params['filterId'])
}
let port = window.location.port.length > 0 ? ':' + window.location.port : ''
const sasPath = this.sasService.getExecutionPath()
let filter = '&filter=' + filter_pk
let downUrl = `${window.location.protocol}//${
window.location.hostname
}${port}/${sasPath}/?_program=${
this.sasjsConfig.appLoc
}/services/public/getrawdata&type=WEB${
this.webQueryTab ? 'TAB' : 'CSV'
}&table=${this.tableTitle}${filter}`
this.webQueryText = downUrl.replace(/ /gim, '%20')
}
public copyToClip() {
navigator.clipboard.writeText(this.webQueryText)
}
public goToViewer() {
this.router.navigateByUrl('/view/data')
}
public showTableSelect() {
this.tableFlag = !this.tableFlag
}
public checkExpand(event: any) {}
public collapseLibraryItems(libraries: any, libraryToSkip: any) {
libraries.forEach((library: any) => {
if (library.LIBRARYREF !== libraryToSkip.LIBRARYREF) {
library['expanded'] = false
}
})
}
public loadMoreLibraries() {
if (!this.librariesPaging) {
this.librariesPaging = true
this.helperService.displayLibraries(this.libraries, true)
this.librariesPaging = false
}
}
public treeNodeClicked(event: any, library: any) {
if (event.target.title === 'Collapse') {
this.collapseLibraryItems(this.libraries, library)
}
}
public libraryExpandedChange(expanded: any, library: any) {
if (expanded) {
this.collapseLibraryItems(this.libraries, library)
}
}
public async libraryOnClick(lib: string, library: any, expandOnly?: boolean) {
if (!library['tables']) {
await this.selectTable(lib, false, library)
} else {
if (!!expandOnly) {
library['expanded'] = true
} else {
library['expanded'] = !library['expanded']
}
}
if (library['expanded']) {
this.cdf.detectChanges()
let libTreeSearchInput: HTMLElement | null = document.querySelector(
`#search_${library.LIBRARYREF}`
)
if (libTreeSearchInput) libTreeSearchInput.focus()
if (library && library.libinfo) this.libinfo = library.libinfo
if (this.lib && this.table && !expandOnly) {
this.router
.navigate(['/view/data'], {
skipLocationChange: true,
queryParamsHandling: 'preserve'
})
.then(() => {
this.router.navigate(['/view/data/' + this.lib], {
queryParamsHandling: 'preserve'
})
})
} else if (this.lib && !this.table) {
this.location.replaceState('/view/data/' + this.lib)
}
}
this.collapseLibraryItems(this.libraries, library)
}
public onTableClick(libTable: any, library: any) {
// OPTIMIZATION: Prevent race conditions and destroyed instance errors during rapid table switching
if (this.isTableSwitching) {
return
}
// Clear any existing timeout to prevent stale operations
if (this.switchingTimeout) {
clearTimeout(this.switchingTimeout)
}
// PERFORMANCE: Debounce table switches to prevent rapid successive calls
// This ensures only the final table selection is processed
this.switchingTimeout = setTimeout(() => {
this.lib = library.LIBRARYREF
this.table = libTable
this.selectLibTable(libTable)
this.viewData(0)
}, 50) // 50ms debounce - fast enough for good UX, slow enough to prevent issues
}
public async selectTable(lib: string, initial?: boolean, library?: any) {
library['loadingTables'] = true
this.table = false
this.tableDisable = true
if (lib !== 'Please select library') {
if (globals.viewer.tablesSet && initial) {
this.abortActive = false
this.tableDisable = false
this.tables = globals.viewer.tables
this.libinfo = globals.viewer.libinfo
} else {
this.libinfo = null
await this.sasStoreService
.viewTables(lib)
.then((res: PublicViewtablesServiceResponse) => {
this.abortActive = false
this.tableDisable = false
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
}
globals.viewer.libraries = this.libraries
globals.viewer.library = this.lib
globals.viewer.tables = this.tables
globals.viewer.libinfo = this.libinfo
globals.viewer.tablesSet = true
})
.catch((err: any) => {
this.loggerService.error(err)
this.abortActive = true
this.noData = true
this.tableTitle = ''
this.tableDisable = true
})
}
} else {
this.tableDisable = true
}
// this.getTableState();
globals.viewer.library = this.lib
if (!initial) {
this.clearGlobalsFilter()
}
library['loadingTables'] = false
library['expanded']
? (library['expanded'] = false)
: (library['expanded'] = true)
}
public selectLibTable(ev: string, initial?: boolean) {
if (ev !== 'Please select table') {
this.actionDisable = false
this.libTab = this.lib + '.' + this.table
} else {
this.actionDisable = true
}
globals.viewer.table = ev
if (!initial) {
this.clearGlobalsFilter()
}
this.loggerService.log(this.libTab)
}
private clearGlobalsFilter() {
globals.viewer.filter.libds = ''
globals.viewer.filter.whereClause = ''
globals.viewer.filter.groupLogic = ''
globals.viewer.filter.clauses = []
globals.viewer.filter.cols = []
globals.viewer.filter.vals = []
}
public libTabActive(libraryRef: string, table: string) {
let currentLibTab = libraryRef + '.' + table
if (!this.libTab) {
return false
}
return currentLibTab === this.libTab
}
public treeOnFilter(array: any, arrToFilter: string) {
this.helperService.treeOnFilter(array, arrToFilter)
}
public libraryOnFilter() {
this.helperService.libraryOnFilter(
this.libraries,
this.librariesSearch,
'LIBRARYNAME'
)
globals.viewer.librariesSearch = this.librariesSearch
}
public libraryResetFilter() {
this.helperService.resetArrayFilter(this.libraries)
}
public async viewData(filter_pk: number) {
// CRITICAL: Set switching flag to prevent concurrent operations and race conditions
// This prevents callbacks from accessing destroyed instances during table switching
this.isTableSwitching = true
this.loadingTableView = true
let libDataset: any
if (typeof this.libDataset === 'undefined') {
libDataset = this.lib + '.' + this.table
} else if (
typeof this.lib === 'undefined' &&
typeof this.table === 'undefined'
) {
let ds = []
ds = this.libDataset.split('.')
await this.sasStoreService
.viewLibs()
.then((res: any) => {
this.libraries = res.saslibs
})
.catch((err: any) => {
this.loggerService.error(err)
})
this.lib = ds[0]
if (globals.viewer.startupSet) {
this.tables = globals.viewer.tables
} else {
let library = this.libraries.find(
(lib) => lib.LIBRARYREF.toLowerCase() === this.lib.toLowerCase()
)
await this.sasStoreService
.viewTables(this.lib)
.then((response: any) => {
this.tables = response.mptables.map(function (item: any) {
return item.MEMNAME
})
library.tables = this.tables
globals.viewer.libraries = this.libraries
globals.viewer.tables = this.tables
globals.viewer.startupSet = true
})
.catch((err: any) => {
this.loggerService.error(err)
})
}
this.table = ds[1]
this.tableFlag = false
libDataset = this.libDataset
this.libTab = libDataset
} else {
await this.sasStoreService
.viewLibs()
.then((res: any) => {
this.libraries = res.saslibs
})
.catch((err: any) => {
this.loggerService.error(err)
})
if (typeof this.table !== 'undefined') {
if (globals.viewer.startupSet) {
this.tables = globals.viewer.tables
} else {
await this.sasStoreService
.viewTables(this.lib)
.then((response: PublicViewtablesServiceResponse) => {
this.tables = response.mptables.map(function (item: any) {
return item.MEMNAME
})
})
.catch((err: any) => {
this.loggerService.error(err)
this.router.navigate(['/view/data'], {
queryParamsHandling: 'preserve'
})
})
}
this.tableFlag = false
libDataset = this.lib + '.' + this.table
this.tableTitle = libDataset
this.libTab = libDataset
}
}
if (this.router.url.split('/').length > 3 && libDataset) {
await this.sasStoreService
.viewData(libDataset, filter_pk)
.then((res: any) => {
if (
res.query.length > 0 &&
globals.rootParam === 'view' &&
globals.viewer.filter.clauses.length === 0
) {
globals.viewer.filter.query = this.helperService.deepClone(
res.query
)
globals.viewer.filter.libds = this.route.snapshot.params['libMem']
this.sasStoreService.initializeGlobalFilterClause(
'viewer',
res.cols
)
}
this.abortActive = false
this.filterCols = res.cols
// This will merge $formats types with `Cols` object.
mergeColsRules(this.filterCols, [], res.$viewdata)
this.numberOfRows = res.sasparams[0].NOBS
this.headerPks = res.sasparams[0].PK_FIELDS.split(' ')
if (this.sasjsConfig.serverType === 'SAS9') {
let tableuri = res.sasparams[0].TABLEURI
if (tableuri) {
if (tableuri.length > 0) {
this.tableuri = tableuri
}
}
}
this.hotTable.data = res.viewdata
this.$dataFormats = res.$viewdata
this.dsmeta = res.dsmeta
this.versions = res.versions || []
this.setDSNote()
this.queryText = res.sasparams[0].FILTER_TEXT
// Initialize columns only if we have data
if (res.viewdata && res.viewdata.length > 0) {
let columns: any[] = []
let colArr = []
for (let key in res.viewdata[0]) {
if (key) {
colArr.push(key)
}
}
// NUMBER_FORMAT (display) and HIDDEN (visibility) are the DQ rules
// that make sense in the read-only viewer; READONLY/ROUND are
// editor-only. Build lookups for both from the response.
const numberFormats: { [col: string]: string } = {}
const hiddenCols = new Set<string>()
if (Array.isArray(res.dqrules)) {
for (const rule of res.dqrules) {
if (rule.RULE_TYPE === 'NUMBER_FORMAT') {
numberFormats[rule.BASE_COL] = rule.RULE_VALUE
}
if (rule.RULE_TYPE === 'HIDDEN') {
hiddenCols.add(rule.BASE_COL)
}
}
}
const hiddenColumnIndexes: number[] = []
for (let index = 0; index < colArr.length; index++) {
const col = colArr[index]
const colDef: any = { data: col }
if (numberFormats[col]) {
colDef.renderer = makeNumberFormatRenderer(numberFormats[col])
}
if (hiddenCols.has(col)) {
hiddenColumnIndexes.push(index)
}
columns.push(colDef)
}
this.colNames = colArr
this.hotTable.colHeaders = getDisplayColHeaders(
colArr,
res.cols,
this.useLabels
)
this.hotTable.columns = columns
this.hiddenViewColumns = hiddenColumnIndexes
} else {
// Set empty arrays if no data
this.colNames = []
this.hotTable.colHeaders = []
this.hotTable.columns = []
this.hiddenViewColumns = []
}
// Set cells function
this.hotTable.cells = () => {
return { readOnly: true }
}
// Update hot table settings after data is loaded
this.updateHotTableSettings()
this.tableFlag = false
let ds = []
ds = libDataset.split('.')
this.lib = ds[0]
this.table = ds[1]
if (this.hotTable.data.length === 0) {
this.noData = true
this.tableFlag = true
} else {
this.noData = false
this.tableFlag = false
}
this.noDataReqErr = false
})
.catch((err: any) => {
this.loggerService.error(err)
this.abortActive = true
this.noDataReqErr = true
})
}
// If we move away from the viewer page, we don't renavigate to table view
if (this.router.url.includes('/data') && libDataset) {
if (filter_pk !== 0) {
this.router.navigate(['/view/data/' + libDataset + '/' + filter_pk], {
queryParamsHandling: 'preserve'
})
} else {
this.router.navigate(['/view/data/' + libDataset], {
queryParamsHandling: 'preserve'
})
}
}
let currentTable = this.table
let libraryObject = this.libraries.find(
(lib) => lib.LIBRARYREF.toLowerCase() === this.lib.toLowerCase()
)
if (libraryObject) {
if (globals.viewer.libraries.length > 0) {
this.libraries = globals.viewer.libraries
this.librariesSearch = globals.viewer.librariesSearch
this.libraryOnClick(this.lib, libraryObject, true)
} else {
this.libraryOnClick(this.lib, libraryObject, true)
}
} else {
this.libinfo = []
}
this.table = currentTable ? currentTable : this.table
if (this.libraries) {
this.helperService.displayLibraries(this.libraries)
}
if (this.router.url.includes('/data')) {
this.eventService.closeSidebar()
}
this.loadingTableView = false
// Setup Handsontable after async operations complete
// Original issue: setupHot() called before API responses populated headerPks array
// Solution: Delay ensures both API paths (lines 328 & 886) have chance to set headerPks
setTimeout(() => {
if (!this.noData && !this.noDataReqErr && libDataset) {
this.setupHot()
}
}, 50) // Optimized from 100ms - fast enough for API completion, slow enough to prevent race conditions
// RACE CONDITION PREVENTION: Reset switching flag after setup completion
// This allows new table switches after current operation finishes
setTimeout(() => {
this.isTableSwitching = false
}, 300) // Optimized from 700ms to match reduced setup times
// Fix ARIA accessibility issues after data loading
setTimeout(() => {
this.fixAriaAccessibility()
}, 500)
/**
* This is hacky fix for closing the nav drodpwon, when clicking on the handsontable area.
* Without it, hot table does not steal focus, so it doesn't close the nav.
*/
if (!this.hotInstanceClickListener) {
setTimeout(() => {
let hotInstanceEl = document.getElementById('hotInstance')
if (hotInstanceEl) {
hotInstanceEl.addEventListener('mousedown', (event) => {
setTimeout(() => {
let menuDebugItem: any =
document.querySelector('.debug-switch-item') || undefined
if (menuDebugItem) menuDebugItem.click()
}, 100)
})
this.hotInstanceClickListener = true
}
}, 1000)
}
}
public maxWidthCheker(width: any, col: any) {
if (width > 200) return 200
else return width
}
public openQb() {
this.filter = true
this.cdf.detectChanges()
let libDataset = this.lib + '.' + this.table
this.sasStoreService.setQueryVariables(libDataset, this.filterCols)
}
public async sendClause() {
this.submitLoading = true
let nullVariableArr = []
let emptyVariablesArr = []
// to check number of empty clauses
if (typeof this.clauses === 'undefined') {
this.nullVariables = true
this.submitLoading = false
return
} else {
let query = this.clauses.queryObj
if (query[0].elements.length < 1) {
// Clear cached filtering data
if (globals.rootParam === 'view') {
globals.viewer.filter.clauses = []
globals.viewer.filter.query = []
globals.viewer.filter.groupLogic = ''
}
this.router.navigate(['/view/data/' + 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) {
// not allow to submit if some of clauses are empty
this.nullVariables = true
this.submitLoading = false
return
} else {
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) => {
this.queryText = res.result[0].FILTER_TEXT
let id = res.result[0].FILTER_RK
this.router.navigate(['/view/data/' + this.libds + '/' + id], {
queryParamsHandling: 'preserve'
})
this.viewData(id)
this.filter = false
})
.catch((err: any) => {
this.loggerService.error(err)
})
this.submitLoading = false
}
}
public removeQuery() {
this.sasStoreService.removeClause()
}
public 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])
}
}
private setDSNote() {
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 = ''
}
}
/**
* CRITICAL CLEANUP (workaround needed for HOT version 16 and above): Safely destroys Handsontable instances
*
* Purpose: Prevents "instance destroyed" errors and memory leaks during table switching
*
* Called from:
* - ngOnDestroy() - component cleanup
*
* Safety features:
* - Checks if instance exists and is not already destroyed
* - Try-catch prevents errors during destruction
* - Sets instance to null to prevent stale references
*/
private cleanupHotInstance() {
if (this.hotInstance && !this.hotInstance.isDestroyed) {
try {
this.hotInstance.destroy()
} catch (error) {
console.warn('Error destroying Handsontable instance:', error)
}
}
this.hotInstance = null
this.hooksAttached = false
}
/**
* PERFORMANCE: Configures Handsontable with enhanced error handling (workaround needed for HOT version 16 and above)
*
* 1. Duplicate call prevention (500ms window)
* 2. Multiple validation checks to prevent race conditions
* 3. Forced render for immediate primary key styling
*
* Instance lifecycle is managed by Angular's hot-table component via [data] and [settings] bindings.
* This method only applies additional config that can't go through bindings (hooks, PK styling).
*/
private setupHot() {
const now = Date.now()
if (now - this.lastSetupTime < 500) {
return
}
this.lastSetupTime = now
// VALIDATION: Don't setup if we're currently switching tables or still loading
if (this.loadingTableView || !this.libDataset) {
return
}
if (!this.hotInstance || this.hotInstance.isDestroyed) {
this.hotInstance = this.hotTableComponent?.hotInstance
}
if (this.hotInstance && !this.hotInstance.isDestroyed) {
this.configureHotInstance()
return
}
// Instance not ready yet — Angular may still be creating the component
setTimeout(() => {
if (this.isTableSwitching || this.loadingTableView || !this.libDataset) {
return
}
this.hotInstance = this.hotTableComponent?.hotInstance
this.configureHotInstance()
}, 250)
}
private hooksAttached = false
/**
* Applies settings that can't go through Angular [settings] binding:
* - Primary key column header styling
* - Column width cap
* - ARIA accessibility hooks (attached once per instance)
*/
private configureHotInstance() {
if (!this.hotInstance || this.hotInstance.isDestroyed) return
this.hotInstance.updateSettings({
height: this.hotTable.height,
modifyColWidth: (width: any, col: any) => {
if (width > 500) return 500
else return width
},
afterGetColHeader: (col: number, th: any) => {
// CRITICAL: Same safety checks as initial callback to prevent destroyed instance errors
if (!this.hotInstance || this.hotInstance.isDestroyed) {
th.classList.add(globals.handsontable.darkTableHeaderClass)
return
}
try {
const column = this.hotInstance.colToProp(col) as string
// PRIMARY KEY STYLING: Apply special styling to PK columns (populated from API response)
const isPKCol = column && this.headerPks.indexOf(column) > -1
if (isPKCol) th.classList.add('primaryKeyHeaderStyle')
// DARK MODE: Apply to all headers
th.classList.add(globals.handsontable.darkTableHeaderClass)
} catch (error) {
// SAFETY NET: Ensure basic styling is always applied
th.classList.add(globals.handsontable.darkTableHeaderClass)
}
}
})
// Add hooks for accessibility fixes
// Hooks are attached once per instance to avoid accumulating duplicate listeners
if (!this.hooksAttached) {
this.hotInstance.addHook('afterRender', () => {
// Fix ARIA accessibility issues after each render
this.fixAriaAccessibility()
})
this.hotInstance.addHook('afterChange', () => {
// Fix ARIA accessibility issues after any data change
setTimeout(() => {
this.fixAriaAccessibility()
}, 50)
})
this.hooksAttached = true
}
// Force immediate render to apply primary key styling
// Without this, styling would wait for ~2 seconds to be applied
// (workaround needed for HOT version 16 and above)
this.hotInstance.render()
}
async loadWithParameters() {
this.switchFlag = true
this.tableTitle = this.route.snapshot.params['libMem'] || 0
let id = this.route.snapshot.params['filterId'] || '0'
this.urlFilterPk = parseInt(id)
let libds = this.route.snapshot.params['libMem']
this.libDataset = libds
if (!libds.includes('.')) this.lib = libds
await this.viewData(this.urlFilterPk)
if (this.noData) {
setTimeout(() => {
this.tableFlag = true
}, 1200)
} else {
setTimeout(() => {
this.tableFlag = false
}, 1200)
}
}
async loadWithoutParameters() {
this.switchFlag = false
this.librariesLoading = true
if (globals.viewer.startupSet) {
setTimeout(() => {
this.libraries = globals.viewer.libraries
this.librariesSearch = globals.viewer.librariesSearch
this.lib = globals.viewer.library
this.librariesTreeExpanded = true
this.librariesLoading = false
this.helperService.displayLibraries(this.libraries)
}, 100)
} else {
this.noDataReqErr = false
await this.sasStoreService
.viewLibs()
.then((res: PublicViewlibsServiceResponse) => {
this.libraries = res.saslibs
globals.viewer.libraries = this.libraries
globals.viewer.startupSet = true
this.librariesLoading = false
this.helperService.displayLibraries(this.libraries)
})
.catch((err: any) => {
this.loggerService.error(err)
this.librariesLoading = false
this.noDataReqErr = true
})
}
}
ngAfterViewChecked() {
this.scheduleFilterOverflowCheck()
}
/**
* Measures whether the applied-filter clause fits the collapsed single line,
* and shows or hides the chevron accordingly. The measurement is deferred
* because `filterOverflows` and `filterExpanded` are bound in this view -
* setting them inside the change-detection cycle would raise
* ExpressionChangedAfterItHasBeenChecked.
*/
private scheduleFilterOverflowCheck() {
const el = this.infoBar?.nativeElement
if (!el) {
return
}
const text = el.querySelector('.infoBar-text') as HTMLElement | null
if (!text) {
return
}
// re-measure only when the clause, the available width, or the state changes
const key = `${this.queryText}|${text.clientWidth}|${this.filterExpanded}`
if (key === this.filterMeasuredKey) {
return
}
this.filterMeasuredKey = key
setTimeout(() => {
// while expanded the clause wraps, so there is nothing to measure
if (this.filterExpanded) {
return
}
this.filterOverflows = text.scrollWidth > text.clientWidth
})
}
public toggleFilterPanel() {
this.filterExpanded = !this.filterExpanded
}
ngAfterViewInit() {
// Fix ARIA accessibility issues after table initialization
setTimeout(() => {
this.fixAriaAccessibility()
}, 1000)
}
ngOnDestroy() {
// Proper component destruction to prevent memory leaks and errors
this._queryParams?.unsubscribe()
// Prevent any new operations during cleanup
this.isTableSwitching = true
// Clear any pending debounced table switches
if (this.switchingTimeout) {
clearTimeout(this.switchingTimeout)
this.switchingTimeout = null
}
// Safely destroy Handsontable instance
this.cleanupHotInstance()
// Clean up ARIA accessibility observers
if (this.ariaObserver) {
this.ariaObserver.disconnect()
this.ariaObserver = undefined
}
// Clear ARIA check intervals
if (this.ariaCheckInterval) {
clearInterval(this.ariaCheckInterval)
this.ariaCheckInterval = undefined
}
}
/**
* 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'
]
})
}
}
async ngAfterContentInit() {
if (this.hotTable.data.length > 0) {
this.tableFlag = true
}
this._query = this.sasStoreService.query.subscribe((query: any) => {
this.whereString = query.string
this.clauses = query.obj
this.libds = query.libds
})
if (typeof this.route.snapshot.params['libMem'] !== 'undefined') {
this.loadWithParameters()
} else {
this.loadWithoutParameters()
}
}
}
<app-sidebar (scrolledToBottom)="loadMoreLibraries()">
@if (librariesLoading) {
<div class="my-10-mx-auto text-center">
<clr-spinner clrMedium></clr-spinner>
</div>
}
<clr-tree>
@if (libraries) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchLibTreeInput
[(ngModel)]="librariesSearch"
placeholder="Libraries"
name="input"
(keyup)="libraryOnFilter()"
autocomplete="off"
/>
@if (searchLibTreeInput.value.length < 1) {
<clr-icon aria-hidden="true" shape="search"></clr-icon>
}
@if (searchLibTreeInput.value.length > 0) {
<clr-icon
(click)="librariesSearch = ''; libraryOnFilter()"
aria-label="Clear search"
shape="times"
></clr-icon>
}
</div>
</clr-tree-node>
}
@for (library of libraries; track library) {
@if (!library['hidden'] && library['inForeground']) {
<clr-tree-node
(click)="treeNodeClicked($event, library)"
[(clrExpanded)]="library['expanded']"
[clrLoading]="library['loadingTables'] && !library.tables"
[class.clr-expanded]="library['expanded']"
>
<p
(click)="
lib = library.LIBRARYREF;
libraryOnClick(library.LIBRARYREF, library)
"
class="m-0 cursor-pointer"
>
<clr-icon aria-hidden="true" shape="rack-server"></clr-icon>
{{ library.LIBRARYNAME }}
</p>
@if (library['tables']) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchTreeInput
[id]="'search_' + library.LIBRARYREF"
placeholder="Tables"
name="input"
[(ngModel)]="library['searchString']"
(keyup)="treeOnFilter(library, 'tables')"
autocomplete="off"
/>
@if (searchTreeInput.value.length < 1) {
<clr-icon aria-hidden="true" shape="search"></clr-icon>
}
@if (searchTreeInput.value.length > 0) {
<clr-icon
(click)="
searchTreeInput.value = '';
library['searchString'] = '';
treeOnFilter(library, 'tables')
"
aria-label="Clear search"
shape="times"
></clr-icon>
}
</div>
</clr-tree-node>
}
@for (
libTable of library['tables'];
track libTable;
let index = $index
) {
<clr-tree-node>
<clr-tooltip
*ngVar="
index + 1 >
licenceState.value.tables_in_library_limit as tableLocked
"
>
@if (libTable.length > 0) {
<button
clrTooltipTrigger
(click)="
!tableLocked ? onTableClick(libTable, library) : ''
"
class="clr-treenode-link"
[class.dc-locked-control]="tableLocked"
[class.active]="libTabActive(library.LIBRARYREF, libTable)"
>
@switch (libTable.includes('-FC')) {
@case (true) {
<clr-icon aria-hidden="true" shape="bolt"></clr-icon>
}
@case (false) {
<clr-icon aria-hidden="true" shape="table"></clr-icon>
}
}
{{ libTable.replace('-FC', '') }}
</button>
}
@if (tableLocked) {
<clr-tooltip-content
clrPosition="bottom-right"
clrSize="lg"
*clrIfOpen
>
<span>
To unlock all tables, contact
support@datacontroller.io
</span>
</clr-tooltip-content>
}
</clr-tooltip>
</clr-tree-node>
}
</clr-tree-node>
}
}
</clr-tree>
@if (librariesPaging) {
<div class="w-100 text-center">
<span class="spinner spinner-sm"> Loading... </span>
</div>
}
</app-sidebar>
<div class="content-area">
@if (nullVariables) {
<div class="modal z-index-highest">
<div class="modal-dialog" role="dialog" aria-hidden="true">
<div class="modal-content">
<div class="modal-header">
<button aria-label="Close" class="close" type="button">
<clr-icon aria-hidden="true" shape="close"></clr-icon>
</button>
<h3 class="modal-title">Error</h3>
</div>
<div class="modal-body">
<p>You cannot submit empty clauses</p>
</div>
<div class="modal-footer">
<button
class="btn btn-sm btn-primary"
(click)="nullVariables = false"
type="button"
>
Ok
</button>
</div>
</div>
</div>
</div>
}
@if (nullVariables) {
<div class="modal-backdrop" aria-hidden="true"></div>
}
<clr-modal [(clrModalOpen)]="openDownload" [clrModalSize]="'md'">
<h3 class="modal-title center text-center color-darker-gray">Download</h3>
<div class="modal-body">
<div class="clr-col-md-6">
<clr-select-container class="download-select">
<label>Please choose download format</label>
<select [(ngModel)]="downloadFormat" clrSelect>
<option value="CSV">CSV</option>
<option value="SAS">Datalines (cards file)</option>
<option value="PGSQL_DDL">DDL (PGSQL Flavour)</option>
<option value="SAS_DDL">DDL (SAS Flavour)</option>
<option value="TSQL_DDL">DDL (TSQL Flavour)</option>
<option value="EXCEL">Excel (.xlsx)</option>
<option value="MARKDOWN">Markdown (.md)</option>
</select>
</clr-select-container>
</div>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-sm btn-outline"
(click)="openDownload = false"
>
Cancel
</button>
<button
[id]="tableTitle"
type="submit"
class="btn btn-sm btn-success-outline"
(click)="
downloadFormat.includes('DDL') ? downloadDDL() : downloadData()
"
>
Ok
</button>
</div>
</clr-modal>
<clr-modal [(clrModalOpen)]="webQuery" [clrModalSize]="'lg'">
<h3 class="modal-title center text-center color-darker-gray">
Web Query URL
</h3>
<div class="modal-body web-query">
<div class="row">
<div class="clr-col-lg-12 clr-col-md-12 clr-col-sm-12 clr-col-xs-12">
<div class="card no-borders">
<div class="card-header d-flex justify-content-between">
<span>Copy the below into your preferred client tool:</span>
<div class="d-block mr-0" class="btn-group">
<div
class="radio btn"
(click)="webQueryTab = true; showWebQuery()"
>
<input
type="radio"
name="btn-group-demo-radios"
[checked]="webQueryTab"
/>
<label>TAB</label>
</div>
<div
class="radio btn"
(click)="webQueryTab = false; showWebQuery()"
>
<input
type="radio"
name="btn-group-demo-radios"
[checked]="!webQueryTab"
/>
<label>CSV</label>
</div>
</div>
</div>
<div class="card-block web-query-wrapper word-break-all">
<textarea
clrTextarea
class="web-query-text"
rows="4"
cols="50"
#cliCommandInput
(focus)="onCliCommandFocus($event)"
type="text"
[value]="webQueryText"
readonly
>
</textarea>
</div>
<div class="card-footer">
<button
class="btn btn-sm btn-link"
[ngxClipboard]="cliCommandInput"
(click)="copyToClip()"
>
copy to clipboard
</button>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-sm btn-outline"
(click)="webQuery = false"
>
close
</button>
<!-- <button type="submit" class="btn btn-sm btn-success-outline" (click)="downloadData()">Ok</button> -->
</div>
</clr-modal>
<clr-modal
[(clrModalOpen)]="filter"
[clrModalSize]="'xl'"
[clrModalClosable]="false"
aria-modal="true"
class="filter-modal"
>
<h3 class="modal-title center text-center color-darker-gray">
Filter for table:<span> {{ libTab }} </span>
</h3>
<div class="modal-body">
@if (filter) {
<app-query #queryFilter></app-query>
}
</div>
<div class="modal-footer">
<button (click)="resetFilter()" type="button" class="btn btn-sm btn-link">
reset filter
</button>
<button
type="button"
class="btn btn-outline btn-sm"
(click)="filter = false; removeQuery()"
>
Cancel
</button>
<button
[clrLoading]="submitLoading"
type="submit"
class="btn btn-sm btn-success-outline"
(click)="sendClause()"
>
Ok
</button>
</div>
</clr-modal>
<clr-modal [(clrModalOpen)]="queryErr">
<h3 class="modal-title">Error</h3>
<div class="modal-body">
<p>{{ queryErrMessage }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline" (click)="queryErr = false">
Cancel
</button>
<button type="button" class="btn btn-primary" (click)="queryErr = false">
Ok
</button>
</div>
</clr-modal>
@if (loadingTableView) {
<div class="loadingSpinner">
<span class="spinner"> Loading... </span>
<div class="mt-10">
<p cds-text="section">Loading Table Viewer</p>
</div>
</div>
}
@if (!loadingTableView) {
<div class="card no-borders h-100 d-flex clr-flex-column">
@if (table) {
<div
class="header-row clr-row justify-content-between clr-justify-content-center w-100 m-0"
>
<section class="form-block table-search-wrapper sw clr-col-md">
<form class="d-flex align-items-center" clrForm>
<div class="input-wrapper">
<input
#searchEl
(keyup.enter)="searchTable(searchEl)"
clrInput
[type]="searchNumeric ? 'number' : 'text'"
placeholder="Search"
name="search-input"
/>
@if (!searchLoading) {
<clr-icon
(click)="searchTable(searchEl)"
aria-label="Search"
shape="search"
></clr-icon>
}
@if (searchLoading) {
<span class="spinner spinner-inline"> Loading... </span>
}
</div>
<clr-checkbox-container>
<clr-checkbox-wrapper>
<input
type="checkbox"
clrCheckbox
[(ngModel)]="searchNumeric"
name="numeric_check"
/>
<label>Numeric</label>
</clr-checkbox-wrapper>
</clr-checkbox-container>
</form>
</section>
<div class="title-col clr-col-auto clr-flex-column clr-flex-sm-row">
<p
cds-text="section"
class="clr-flex-column d-flex clr-flex-sm-row clr-align-items-center clr-justify-content-center"
>
<clr-tooltip class="d-flex clr-align-items-center">
<clr-icon
clrTooltipTrigger
(click)="datasetInfo = true"
aria-label="View dataset meta info"
shape="info-circle"
class="is-highlight cursor-pointer"
size="24"
></clr-icon>
@if (tableTitle?.includes('-FC')) {
<clr-icon
aria-hidden="true"
shape="bolt"
class="color-yellow mr-5"
></clr-icon>
}
@if (tableTitle && tableTitle.length > 0) {
<span clrTooltipTrigger>
{{ tableTitle?.replace('-FC', '') }}
</span>
}
@if (this.dsNote && this.dsNote.length > 0) {
<clr-tooltip-content
clrPosition="bottom-left"
clrSize="lg"
*clrIfOpen
>
{{ this.dsNote }}
</clr-tooltip-content>
}
</clr-tooltip>
@if (tableTitle && tableTitle.length > 0) {
@if (numberOfRows !== null) {
<span>
({{ numberOfRows | thousandSeparator: ',' }}
{{ numberOfRows! === 1 ? 'row' : 'rows' }},
{{ filterCols.length
}}{{ filterCols.length === 1 ? ' col' : ' cols' }})
</span>
}
<clr-icon
(click)="reloadTableData()"
aria-label="Reload data"
class="refresh-table"
shape="refresh"
></clr-icon>
}
</p>
</div>
<div class="options-col clr-col-md">
@if (tableTitle && !abortActive) {
<clr-dropdown
class="options-dropdown"
[clrCloseMenuOnItemClick]="true"
>
<button
type="button"
class="btn btn-sm btn-outline filterSide"
clrDropdownTrigger
>
<clr-icon aria-hidden="true" shape="cog" size="15"></clr-icon>
options
</button>
<clr-dropdown-menu clrPosition="bottom-right" *clrIfOpen>
<div (click)="newViewbox()" clrDropdownItem>
<clr-icon aria-hidden="true" shape="view-cards"></clr-icon>
<span>Viewboxes</span>
</div>
@if (tableEditExists()) {
<div (click)="editTable()" clrDropdownItem>
<clr-icon aria-hidden="true" shape="pencil"></clr-icon>
<span>Edit</span>
</div>
}
@if (tableuri) {
<div (click)="goToLineage()" clrDropdownItem>
<clr-icon aria-hidden="true" shape="switch"></clr-icon>
<span>Lineage</span>
</div>
}
<div (click)="openQb()" clrDropdownItem>
<clr-icon aria-hidden="true" shape="filter"></clr-icon>
<span>Filter</span>
</div>
<div (click)="openDownload = true" clrDropdownItem>
<clr-icon aria-hidden="true" shape="download"></clr-icon>
<span>Download</span>
</div>
<div (click)="showWebQuery()" clrDropdownItem>
<clr-icon
aria-hidden="true"
shape="download-cloud"
></clr-icon>
<span>Web Query URL</span>
</div>
</clr-dropdown-menu>
</clr-dropdown>
}
</div>
@if (
queryText !== '1=1' &&
!['', ' '].includes(queryText) &&
!abortActive
) {
<div
#infoBar
class="clr-col-md-12 infoBar"
[class.expanded]="filterExpanded"
>
<span class="infoBar-text"
>FILTER : <b>{{ queryText }}</b></span
>
@if (filterOverflows) {
<button
type="button"
class="infoBar-toggle"
[attr.aria-expanded]="filterExpanded"
[attr.aria-label]="
filterExpanded
? 'Collapse the filter clause'
: 'Expand the filter clause'
"
(click)="toggleFilterPanel()"
>
<clr-icon aria-hidden="true" shape="caret down"></clr-icon>
</button>
}
</div>
}
</div>
}
@if (!lib && !table && !noDataReqErr && !noData) {
<div class="no-table-selected">
<img
src="images/select-library.png"
class="select-table-icon"
alt="select a library icon"
/>
<h3 class="text-center color-gray mt-10" cds-text="section">
Please select a library
</h3>
</div>
}
@if (!noData && !noDataReqErr && !table && lib) {
<div
class="header-row clr-row border-bottom-divider justify-content-between w-100 m-0"
>
<section
class="form-block table-search-wrapper sw clr-col-md"
></section>
<div class="title-col clr-col-auto mt-15 mb-15">
<p cds-text="section">
{{ lib }}
</p>
<clr-icon
(click)="reloadLibInfo()"
aria-label="Reload library info"
class="refresh-table"
shape="refresh"
></clr-icon>
</div>
<div class="options-col clr-col-md"></div>
</div>
<div class="text-center mt-10">
@if (libinfo === null) {
<clr-spinner clrMedium></clr-spinner>
}
</div>
@if (libinfo !== null) {
<div class="no-table-selected-info pointer-events-none">
<img
src="images/select-table.png"
class="select-table-icon"
alt="select table icon"
/>
<p class="text-center color-gray mt-10" cds-text="section">
Please select a table
</p>
</div>
}
@if (libinfo !== null) {
<div class="libinfo m-0 clr-row">
@if (libinfo.length < 1) {
<p class="text-center m-0 w-100">
No library info found. Click
<clr-icon
(click)="reloadLibInfo()"
aria-label="Reload library info"
class="refresh-table m-0"
shape="refresh"
></clr-icon>
button to refresh.
</p>
}
@if (libinfo.length > 0) {
<table>
@if (libinfo[0].ENGINE !== '') {
<tr>
<td class="m-0">ENGINE:</td>
<td class="m-0 font-bold">
{{ libinfo[0] ? libinfo[0].ENGINE : '' }}
</td>
</tr>
}
@if (libinfo[0].LIBID !== '') {
<tr>
<td class="m-0">LIBID:</td>
<td class="m-0 font-bold">
{{ libinfo[0] ? libinfo[0].LIBID : '' }}
</td>
</tr>
}
@if (libinfo[0].LIBNAME !== '') {
<tr>
<td class="m-0">LIBNAME:</td>
<td class="m-0 font-bold">
{{ libinfo[0] ? libinfo[0].LIBNAME : '' }}
</td>
</tr>
}
@if (libinfo[0].LIBSIZE !== null) {
<tr>
<td class="m-0">LIBSIZE:</td>
<td class="m-0 font-bold">
{{ libinfo[0] ? (libinfo[0].LIBSIZE | convertSize) : '' }}
</td>
</tr>
}
@if (libinfo[0].OWNERS !== '') {
<tr>
<td class="m-0">OWNERS:</td>
<td class="m-0 font-bold">
{{ libinfo[0] ? libinfo[0].OWNERS : '' }}
</td>
</tr>
}
@if (libinfo[0].PATHS !== '') {
<tr>
<td class="m-0">PATHS:</td>
<td class="m-0 font-bold">
{{ libinfo[0] ? libinfo[0].PATHS : '' }}
</td>
</tr>
}
@if (libinfo[0].PERMS !== '') {
<tr>
<td class="m-0">PERMS:</td>
<td class="m-0 font-bold">
{{ libinfo[0] ? libinfo[0].PERMS : '' }}
</td>
</tr>
}
@if (libinfo[0].SCHEMAS !== '') {
<tr>
<td class="m-0">SCHEMAS:</td>
<td class="m-0 font-bold">
{{ libinfo[0] ? libinfo[0].SCHEMAS : '' }}
</td>
</tr>
}
@if (libinfo[0].TABLE_CNT !== null) {
<tr>
<td class="m-0">TABLE_CNT:</td>
<td class="m-0 font-bold">
{{ libinfo[0] ? libinfo[0].TABLE_CNT : '' }}
</td>
</tr>
}
@if (libinfo[0].CATALOG_CNT !== null) {
<tr>
<td class="m-0">CATALOG_CNT:</td>
<td class="m-0 font-bold">
{{ libinfo[0] ? libinfo[0].CATALOG_CNT : '' }}
</td>
</tr>
}
</table>
}
</div>
}
}
@if (noData || noDataReqErr) {
<div class="card-block noData">
<clr-icon
aria-hidden="true"
shape="warning-standard"
size="60"
class="is-info"
></clr-icon>
@if (noData) {
<h3 class="text-center color-gray">
No data found with given conditions
</h3>
}
@if (noDataReqErr) {
<h3 class="text-center color-gray">
No data found due to sas request error
</h3>
}
</div>
}
@if (!noData && !noDataReqErr && table) {
<div class="clr-flex-1">
<div class="hot-wrapper clr-flex-1">
<hot-table
#hotInstance
id="hotTable"
class="view-hot"
[data]="hotTable.data"
[settings]="hotTableSettings"
>
</hot-table>
</div>
</div>
}
<div>
@if (
licenceState.value.viewer_rows_allowed !== Infinity &&
hotTable.data &&
hotTable.data.length > licenceState.value.viewer_rows_allowed
) {
<p class="mt-2-i w-100 text-center">
To display more than
{{ licenceState.value.viewer_rows_allowed }} rows, contact
<contact-link />
</p>
}
</div>
</div>
}
</div>
<app-dataset-info
[(open)]="datasetInfo"
[dsmeta]="dsmeta"
[versions]="versions"
(rowClicked)="datasetInfoModalRowClicked($event)"
>
</app-dataset-info>
<app-viewboxes [(viewboxModal)]="viewboxOpen"></app-viewboxes>
./viewer.component.scss