src/app/multi-dataset/multi-dataset.component.ts
| encapsulation | ViewEncapsulation.None |
| selector | app-multi-dataset |
| styleUrls | ./multi-dataset.component.scss |
| templateUrl | ./multi-dataset.component.html |
Properties |
|
Methods |
|
HostBindings |
Accessors |
constructor(eventService: EventService, licenceService: LicenceService, helperService: HelperService, sasStoreService: SasStoreService, spreadsheetService: SpreadsheetService, sasService: SasService, cdr: ChangeDetectorRef)
|
||||||||||||||||||||||||
|
Parameters :
|
| class.content-container |
Type : boolean
|
Default value : true
|
| Private deselectAllParsedDatasets |
deselectAllParsedDatasets()
|
|
Returns :
void
|
| Private deselectAllSubmittedCsvDatasets |
deselectAllSubmittedCsvDatasets()
|
|
Returns :
void
|
| Public downloadFile | ||||||
downloadFile(response: any)
|
||||||
|
Parameters :
Returns :
void
|
| dynamicCellValidations |
dynamicCellValidations()
|
|
Returns :
void
|
| Async fetchDataset | ||||||
fetchDataset(libds: string)
|
||||||
|
Fetches the table for given datasets params LIBRARY.TABLE
Parameters :
Returns :
Promise<EditorsGetDataServiceResponse | undefined>
|
| Private getDatasetsFromHot |
getDatasetsFromHot()
|
|
Format: LIBRARY.TABLE
Returns :
string[]
list of strings containing datasets in the HOT user input Format: LIBRARY.TABLE |
| Public getFromGlobals |
getFromGlobals()
|
|
Returns :
void
|
| initHot |
initHot()
|
|
Returns :
void
|
| Private initializeHotSettings |
initializeHotSettings()
|
|
Returns :
void
|
| initUserInputHot |
initUserInputHot()
|
|
Returns :
void
|
| Private isValidDatasetFormat | ||||||
isValidDatasetFormat(name: string)
|
||||||
|
Valid dataset format includes:
example: LIB123.TABLE_123
Parameters :
Returns :
any
|
| Private isValidDatasetReference | ||||||||
isValidDatasetReference(datasetRef: string)
|
||||||||
|
Checks if @param datasetRef is valid variable which references library and table
Parameters :
Returns :
boolean
|
| markUnmatchedRows | ||||||
markUnmatchedRows(row: number)
|
||||||
|
Parameters :
Returns :
void
|
| Public maxWidthCheker |
maxWidthCheker(width: any, col: any)
|
|
Returns :
any
|
| ngAfterContentInit |
ngAfterContentInit()
|
|
Returns :
void
|
| ngAfterViewInit |
ngAfterViewInit()
|
|
Returns :
void
|
| ngOnInit |
ngOnInit()
|
|
Returns :
void
|
| Async onAutoDetectColumns |
onAutoDetectColumns()
|
|
Try to find the datasets in the provided file by looking in the sheet names and testing if they are in the scope of the dataset naming convention. isValidDatasetFormat
Returns :
any
|
| onDiscard |
onDiscard()
|
|
Returns :
void
|
| onDiscardFile |
onDiscardFile()
|
|
Returns :
void
|
| Async onFileChange | ||||||
onFileChange(event: any)
|
||||||
|
Parameters :
Returns :
any
|
| Async onMultiCsvFiles | ||||||
onMultiCsvFiles(files: File[])
|
||||||
|
Parameters :
Returns :
any
|
| onParsedDatasetClick | ||||||
onParsedDatasetClick(parsedDataset: ParsedDataset)
|
||||||
|
Parameters :
Returns :
void
|
| Async onStartParsingFile |
onStartParsingFile()
|
|
Returns :
any
|
| onSubmitAll |
onSubmitAll()
|
|
Returns :
void
|
| onSubmittedCsvDatasetClick | ||||||
onSubmittedCsvDatasetClick(submittedDataset: SubmittedCsvDatasetResult)
|
||||||
|
Parameters :
Returns :
void
|
| onUserInputDatasetsChange |
onUserInputDatasetsChange()
|
|
Returns :
any
|
| Private parseDatasetFromCsvName | ||||||
parseDatasetFromCsvName(fileName: string)
|
||||||
|
Parameters :
Returns :
any
|
| Private Async parseExcelSheetNames |
parseExcelSheetNames()
|
|
Read the file minimally just to get the sheet names, not reading full file to help boost the performance
Returns :
Promise<string[]>
sheet names in string array |
| Async reSubmitTable | ||||||
reSubmitTable(activeSubmittedDataset: ParsedDataset)
|
||||||
|
Parameters :
Returns :
any
|
| Async submitCsvFiles |
submitCsvFiles()
|
|
Submits attached CSVs which are matched with existing datasets
Returns :
any
|
| Async submitTables | ||||||||
submitTables(explicitDatasets?: string[])
|
||||||||
|
Sends tables found in an excel to the SAS sequentially in the array will be sent. eg. ['lib1.table_1', 'lib1.table_2']
Parameters :
Returns :
any
|
| Private workbookLoaded |
workbookLoaded()
|
|
Returns :
Promise<XLSX.WorkBook>
Promise once workbook is loaded because use XLSX.read in the background |
| Public afterGetColHeader |
Type : unknown
|
Default value : baseAfterGetColHeader
|
| Public autoDetectingColumns |
Type : boolean
|
Default value : false
|
| contentAreaRef |
Type : ElementRef
|
Decorators :
@ViewChild('contentArea', {static: true})
|
| contentContainerClass |
Type : unknown
|
Default value : true
|
Decorators :
@HostBinding('class.content-container')
|
| Public csvFiles |
Type : UploadFile[]
|
Default value : []
|
| Public csvSubmitting |
Type : boolean
|
Default value : false
|
| Public datasetsLoading |
Type : boolean
|
Default value : false
|
| Public fileLoadingState |
Type : FileLoadingState
|
Default value : FileLoadingState.notSelected
|
| Public FileLoadingState |
Type : unknown
|
Default value : FileLoadingState
|
| Public hotInstance |
Type : Handsontable
|
| Public hotInstanceUserDataset |
Type : Handsontable
|
| Public hotMainTableSettings |
Type : Handsontable.GridSettings
|
Default value : {}
|
| Public hotTableLicenseKey |
Type : string | undefined
|
Default value : undefined
|
| hotTableMainComponent |
Type : HotTableComponent
|
Decorators :
@ViewChild('hotInstanceMain', {static: false})
|
| Public hotTableMaxRows |
Type : unknown
|
Default value : this.licenceState.value.viewer_rows_allowed || Infinity
|
| hotTableUserDatasetComponent |
Type : HotTableComponent
|
Decorators :
@ViewChild('hotInstanceUserDataset', {static: false})
|
| Public hotUserDatasets |
Type : Handsontable.GridSettings
|
Default value : {
colHeaders: ['Library', 'Table'],
data: [
['', ''],
['', ''],
['', ''],
['', ''],
['', ''],
['', ''],
['', ''],
['', ''],
['', ''],
['', ''],
['', ''],
['', '']
],
columns: [
{
type: 'autocomplete',
filter: false,
source: []
},
{
type: 'autocomplete',
filter: false,
source: []
}
],
width: '100%',
height: '305px',
className: ['htDark'],
contextMenu: {
items: {
row_above: {
name: 'Insert Row above'
},
row_below: {
name: 'Insert Row below'
}
}
},
manualRowMove: true,
columnSorting: true,
afterGetColHeader: baseAfterGetColHeader,
stretchH: 'all'
}
|
| Public hotUserDatasetsSettings |
Type : Handsontable.GridSettings
|
Default value : {}
|
| Public Infinity |
Type : unknown
|
Default value : Infinity
|
| Public libsAndTables |
Type : { }
|
Default value : {}
|
| Public licenceState |
Type : unknown
|
Default value : this.licenceService.licenceState
|
| Public matchedDatasets |
Type : string[]
|
Default value : []
|
| Public parsedDatasets |
Type : ParsedDataset[]
|
Default value : []
|
| Public selectedFile |
Type : SelectedFile | null
|
Default value : null
|
| Public sheetNames |
Type : string[]
|
Default value : []
|
| Public showSubmitReasonModal |
Type : boolean
|
Default value : false
|
| Public submitLoading |
Type : boolean
|
Default value : false
|
| Public submitReasonMessage |
Type : string
|
Default value : ''
|
| Public submittedCsvDatasets |
Type : SubmittedCsvDatasetResult[]
|
Default value : []
|
| Public uploadLoading |
Type : boolean
|
Default value : false
|
| Public userInputDatasets |
Type : string
|
Default value : ''
|
| Public workbookInterval |
Type : any
|
| activeParsedDataset |
getactiveParsedDataset()
|
| activeSubmittedCsvDataset |
getactiveSubmittedCsvDataset()
|
| notFoundDatasets |
getnotFoundDatasets()
|
| isHotHidden |
getisHotHidden()
|
| tablesToSubmit |
gettablesToSubmit()
|
| submittingCsv |
getsubmittingCsv()
|
| excelsSubmitted |
getexcelsSubmitted()
|
import {
ChangeDetectorRef,
Component,
ElementRef,
HostBinding,
OnInit,
AfterViewInit,
ViewChild,
ViewEncapsulation
} from '@angular/core'
import {
EventService,
HelperService,
LicenceService,
SasService,
SasStoreService
} from '../services'
import { globals } from '../_globals'
import { EditorsGetDataServiceResponse } from '../models/sas/editors-getdata.model'
import { DcValidator } from '../shared/dc-validator/dc-validator'
import {
prepareDeleteRecordColumn,
seedDeleteRecordColumn
} from '../shared/dc-validator/utils/deleteRecordColumn'
import { DELETE_RECORD_COLUMN_NAME } from '../shared/dc-validator/utils/deleteRecordColumnRule'
import { ExcelRule } from '../models/TableData'
import { HotTableInterface } from '../models/HotTable.interface'
import { Col } from '../shared/dc-validator/models/col.model'
import { SpreadsheetService } from '../services/spreadsheet.service'
import Handsontable, { CellChange, ChangeSource } from 'handsontable'
import { HotTableComponent } from '@handsontable/angular-wrapper'
import { EditorsStageDataSASResponse } from '../models/sas/editors-stagedata.model'
import { baseAfterGetColHeader } from '../shared/utils/hot.utils'
import { ColumnSettings } from 'handsontable/settings'
import { UploadFile } from '@sasjs/adapter'
import { UploadFileResponse } from '../models/UploadFile'
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
import { ParseResult } from '../models/ParseResult.interface'
import XLSX from 'xlsx'
enum FileLoadingState {
reading = 'Reading the file',
parsing = 'Searching for the data in the file',
parsed = 'Searching for the data finished',
/**
* Defualt value
*/
notSelected = 'File not selected'
}
@Component({
selector: 'app-multi-dataset',
templateUrl: './multi-dataset.component.html',
styleUrls: ['./multi-dataset.component.scss'],
encapsulation: ViewEncapsulation.None,
standalone: false
})
export class MultiDatasetComponent implements OnInit, AfterViewInit {
@HostBinding('class.content-container') contentContainerClass = true
@ViewChild('contentArea', { static: true }) contentAreaRef!: ElementRef
public licenceState = this.licenceService.licenceState
public Infinity = Infinity
public workbookInterval: any
public fileLoadingState: FileLoadingState = FileLoadingState.notSelected
public FileLoadingState = FileLoadingState
public hotTableLicenseKey: string | undefined = undefined
public hotTableMaxRows =
this.licenceState.value.viewer_rows_allowed || Infinity
public csvFiles: UploadFile[] = []
public csvSubmitting: boolean = false
public autoDetectingColumns: boolean = false
public selectedFile: SelectedFile | null = null
public parsedDatasets: ParsedDataset[] = []
public submittedCsvDatasets: SubmittedCsvDatasetResult[] = []
public datasetsLoading: boolean = false
public uploadLoading: boolean = false
public submitLoading: boolean = false
public matchedDatasets: string[] = []
public sheetNames: string[] = []
public userInputDatasets: string = ''
public libsAndTables: {
[key: string]: string[]
} = {}
public hotInstance!: Handsontable
public hotInstanceUserDataset!: Handsontable
@ViewChild('hotInstanceMain', { static: false })
hotTableMainComponent!: HotTableComponent
@ViewChild('hotInstanceUserDataset', { static: false })
hotTableUserDatasetComponent!: HotTableComponent
public hotMainTableSettings: Handsontable.GridSettings = {}
public hotUserDatasetsSettings: Handsontable.GridSettings = {}
public showSubmitReasonModal: boolean = false
public submitReasonMessage: string = ''
public hotUserDatasets: Handsontable.GridSettings = {
colHeaders: ['Library', 'Table'],
data: [
['', ''],
['', ''],
['', ''],
['', ''],
['', ''],
['', ''],
['', ''],
['', ''],
['', ''],
['', ''],
['', ''],
['', '']
],
columns: [
{
type: 'autocomplete',
filter: false,
source: []
},
{
type: 'autocomplete',
filter: false,
source: []
}
],
width: '100%',
height: '305px',
className: ['htDark'],
contextMenu: {
items: {
row_above: {
name: 'Insert Row above'
},
row_below: {
name: 'Insert Row below'
}
}
},
manualRowMove: true,
columnSorting: true,
afterGetColHeader: baseAfterGetColHeader,
stretchH: 'all'
}
private initializeHotSettings() {
this.hotMainTableSettings = {
className: ['htDark'],
licenseKey: this.hotTableLicenseKey,
multiColumnSorting: true,
viewportRowRenderingOffset: 50,
manualColumnResize: true,
autoColumnSize: true,
filters: true,
stretchH: 'all',
afterGetColHeader: baseAfterGetColHeader,
modifyColWidth: this.maxWidthCheker,
theme: 'ht-theme-classic'
}
// Exclude data from settings for HOT v16 - it will be loaded manually
const { data, ...settingsWithoutData } = this.hotUserDatasets
this.hotUserDatasetsSettings = {
...settingsWithoutData,
licenseKey: this.hotTableLicenseKey,
theme: 'ht-theme-classic'
}
}
public maxWidthCheker(width: any, col: any) {
if (width > 200) return 200
else return width
}
public afterGetColHeader = baseAfterGetColHeader
constructor(
private eventService: EventService,
private licenceService: LicenceService,
private helperService: HelperService,
private sasStoreService: SasStoreService,
private spreadsheetService: SpreadsheetService,
private sasService: SasService,
private cdr: ChangeDetectorRef
) {}
ngOnInit() {
this.licenceService.hot_license_key.subscribe(
(hot_license_key: string | undefined) => {
this.hotTableLicenseKey = hot_license_key
this.initializeHotSettings()
}
)
this.initializeHotSettings()
}
ngAfterViewInit() {
// Ensure HOT instances are properly initialized after view is ready
setTimeout(() => {
if (this.hotTableUserDatasetComponent && !this.hotInstanceUserDataset) {
this.initUserInputHot()
}
if (this.hotTableMainComponent && !this.hotInstance) {
this.initHot()
}
}, 50)
}
ngAfterContentInit(): void {
if (globals.editor.startupSet) {
this.getFromGlobals()
} else {
this.eventService.onStartupDataLoaded.subscribe(() => {
this.getFromGlobals()
})
}
}
public getFromGlobals() {
this.libsAndTables = globals.editor.libsAndTables
const libs: string[] = Object.keys(this.libsAndTables)
if (this.hotUserDatasets?.columns) {
;(this.hotUserDatasets.columns as ColumnSettings[])[0].source = libs
}
}
async onFileChange(event: any) {
const files = event?.target?.files || []
if (files.length < 1) {
this.eventService.showAbortModal(
null,
'No file found.',
null,
'File Upload'
)
return
}
let matchedExtension = ''
for (let file of files) {
const fileExtension = file.name.split('.').pop()
if (!matchedExtension) {
matchedExtension = fileExtension
}
if (matchedExtension !== fileExtension) {
this.eventService.showInfoModal(
'Mixed extensions error',
'Please select files with same extension.'
)
return
}
matchedExtension = fileExtension
}
if (['xlsx', 'xlsm', 'xlm'].includes(matchedExtension)) {
// For EXCEL if multiple files, we only take one (the first one)
this.selectedFile = event.target.files[0]
if (this.selectedFile) {
this.fileLoadingState = FileLoadingState.reading
this.selectedFile.sizeMB = this.spreadsheetService.bytesToMB(
this.selectedFile.size
)
// Read the excel file to be ready
this.spreadsheetService.xlsxReadFile(this.selectedFile!).then((wb) => {
this.fileLoadingState = FileLoadingState.parsing
this.selectedFile!.workbook = wb
})
}
this.initUserInputHot()
// Call onAutoDetectColumns after HOT is initialized
setTimeout(() => {
this.onAutoDetectColumns()
}, 100)
} else if (matchedExtension === 'csv') {
this.onMultiCsvFiles(event.target.files)
} else {
this.eventService.showAbortModal(
null,
'Only excel extensions are allowed. (xlsx)',
null,
'Extension Error'
)
return
}
event.target.value = '' // Reset the upload input
}
async onMultiCsvFiles(files: File[]) {
for (let file of files) {
const fileNameNoExtension = this.parseDatasetFromCsvName(file.name)
if (
this.isValidDatasetFormat(fileNameNoExtension) &&
this.isValidDatasetReference(fileNameNoExtension)
) {
this.csvFiles.push({
file: file,
fileName: file.name
})
}
}
if (!this.csvFiles.length) {
this.eventService.showInfoModal(
'CSV Upload',
'None of the attached CSV file names matched an actual dataset.'
)
return
}
this.csvSubmitting = true
await this.submitCsvFiles()
this.csvSubmitting = false
}
onDiscardFile() {
this.selectedFile = null
this.userInputDatasets = ''
}
async onStartParsingFile() {
this.uploadLoading = true
const datasetFetchingPromises: Promise<
EditorsGetDataServiceResponse | undefined
>[] = []
let datasets: EditorsGetDataServiceResponse[] = []
for (let datasetLibds of this.matchedDatasets) {
const promise = this.fetchDataset(datasetLibds)
datasetFetchingPromises.push(promise)
}
Promise.allSettled(datasetFetchingPromises).then((res) => {
res.forEach((promise) => {
if (promise.status === 'fulfilled' && promise.value)
datasets.push(promise.value)
})
this.uploadLoading = false
// buildDatasetsObjects throws when a getdata response carries the
// reserved delete-record column (see deleteRecordColumn.ts). This chain
// has no .catch, so an uncaught throw here would become an unhandled
// rejection that aborts the loop with nothing shown. Surface it on
// screen, the way the editor flow refuses the same response, rather
// than leaving the user on an empty table selection with the file
// still attached.
let datasetObjects: DatasetsObject[] = []
try {
datasetObjects = this.buildDatasetsObjects(datasets)
} catch (err) {
console.warn('Error building dataset objects', err)
this.eventService.showInfoModal('Upload error', (err as Error).message)
return
}
datasetObjects.forEach((datasetObject) => {
this.parsedDatasets.push({
libds: datasetObject.libds,
includeInSubmission: true,
datasetInfo: datasetObject,
parsingTable: true
})
})
this.workbookLoaded().then((workbook) => {
for (let parsedDataset of this.parsedDatasets) {
this.spreadsheetService
.parseExcelFile({
file: this.selectedFile!,
workbook: workbook,
password: this.selectedFile!.password || undefined,
dcValidator: parsedDataset.datasetInfo.dcValidator!,
headerPks: parsedDataset.datasetInfo.headerPks,
headerArray: parsedDataset.datasetInfo.headerArray,
headerShow: [],
timeHeaders: parsedDataset.datasetInfo.timeHeaders,
dateHeaders: parsedDataset.datasetInfo.dateHeaders,
dateTimeHeaders: parsedDataset.datasetInfo.dateTimeHeaders,
xlRules: parsedDataset.datasetInfo.xlRules
})
.then((parseResult: ParseResult | undefined) => {
this.fileLoadingState = FileLoadingState.parsed
if (parseResult && parseResult.data) {
let datasource: any[] = []
parseResult.data.map((item) => {
let itemObject: any = {}
parseResult.headerShow!.map((header: any, index: number) => {
itemObject[header] = item[index]
})
// If Delete? column is not set in the file, we set it to NO
if (!itemObject['_____DELETE__THIS__RECORD_____'])
itemObject['_____DELETE__THIS__RECORD_____'] = 'No'
datasource.push(itemObject)
})
parsedDataset.datasource = datasource
parsedDataset.parseResult = parseResult
parsedDataset.parsingTable = false
}
})
.catch((error: string) => {
console.warn('Parsing excel file error.', error)
parsedDataset.datasource = []
parsedDataset.includeInSubmission = false
parsedDataset.parsingTable = false
})
}
})
})
}
onSubmitAll() {
if (this.tablesToSubmit.length) {
this.showSubmitReasonModal = true
} else {
this.eventService.showInfoModal(
'No tables to submit',
'Please include at least one table to proceed.'
)
}
}
onDiscard() {
this.parsedDatasets = []
this.matchedDatasets = []
this.selectedFile = null
this.userInputDatasets = ''
this.submitReasonMessage = ''
}
initHot() {
setTimeout(() => {
if (this.hotTableMainComponent?.hotInstance) {
this.hotInstance = this.hotTableMainComponent.hotInstance
// Set height of parsed data to full height of the page content area
const contentAreaHeight = this.contentAreaRef.nativeElement.clientHeight
const hotHeight = `${contentAreaHeight - 160}px`
if (this.activeParsedDataset) {
// Update settings without data - data will be loaded manually
this.hotInstance.updateSettings({
colHeaders: this.activeParsedDataset.datasetInfo.headerColumns,
columns:
this.activeParsedDataset.datasetInfo.dcValidator?.getRules(),
readOnly: true,
height: hotHeight || '300px',
className: ['htDark']
})
// Trigger change detection to avoid ExpressionChangedAfterItHasBeenCheckedError
this.cdr.detectChanges()
// Load data manually - this is required for HOT v16 Angular wrapper
setTimeout(() => {
if (
this.activeParsedDataset &&
this.activeParsedDataset.datasource
) {
this.hotInstance.loadData(this.activeParsedDataset.datasource)
this.hotInstance.render()
}
}, 100)
}
}
}, 100)
}
initUserInputHot() {
setTimeout(() => {
if (this.hotTableUserDatasetComponent?.hotInstance) {
this.hotInstanceUserDataset =
this.hotTableUserDatasetComponent.hotInstance
// Load initial data manually after instance is ready
setTimeout(() => {
if (this.hotUserDatasets.data) {
this.hotInstanceUserDataset.loadData(this.hotUserDatasets.data)
this.hotInstanceUserDataset.render()
}
}, 50)
this.hotInstanceUserDataset.addHook(
'beforeChange',
(changes: (CellChange | null)[], source: ChangeSource) => {
if (changes) {
for (let change of changes) {
if (change && change[3]) {
// CellValue is `unknown` in HOT 18 (was a concrete union before);
// this column is always a dataset name string.
change[3] = (change[3] as string).toUpperCase()
}
}
}
}
)
this.hotInstanceUserDataset.addHook(
'afterChange',
async (changes: CellChange[] | null, source: ChangeSource) => {
if (changes) {
if (source === 'edit') {
await this.onUserInputDatasetsChange()
}
for (let change of changes) {
const row = change[0] as number
this.markUnmatchedRows(row)
}
this.dynamicCellValidations()
this.hotInstanceUserDataset.render()
}
}
)
this.hotInstanceUserDataset.addHook(
'afterRemoveRow',
async (
index: number,
amount: number,
physicalRows: number[],
source?: Handsontable.ChangeSource | undefined
) => {
await this.onUserInputDatasetsChange()
for (let row of physicalRows) {
this.markUnmatchedRows(row)
}
}
)
}
}, 100)
}
dynamicCellValidations() {
if (!this.hotInstanceUserDataset) return
// getData() returns unknown[][] in HOT 18 (was CellValue[][]); this grid's
// data is always library/table name strings.
const hotData = this.hotInstanceUserDataset.getData() as string[][]
hotData.forEach((row, rowIndex) => {
const library = row[0]
if (library && library.length) {
const tables = this.libsAndTables[library]
this.hotInstanceUserDataset.setCellMeta(rowIndex, 1, 'source', tables)
}
})
}
markUnmatchedRows(row: number) {
if (!this.hotInstanceUserDataset) return
const dataAtRow = this.hotInstanceUserDataset.getDataAtRow(row) as number[]
const dataset = `${dataAtRow[0]}.${dataAtRow[1]}`
const cellMetaAtRow = this.hotInstanceUserDataset.getCellMetaAtRow(row)
if (dataAtRow && dataAtRow[0] && dataAtRow[1]) {
if (!this.matchedDatasets.includes(dataset)) {
// getCellMetaAtRow() returns Record<string, unknown>[] in HOT 18, so
// `.col` needs casting back to number for setCellMeta() (same below).
cellMetaAtRow.forEach((cellMeta) => {
this.hotInstanceUserDataset.setCellMeta(
row,
cellMeta.col as number,
'className',
'not-matched'
)
})
} else {
cellMetaAtRow.forEach((cellMeta) => {
this.hotInstanceUserDataset.setCellMeta(
row,
cellMeta.col as number,
'className',
''
)
})
}
} else {
cellMetaAtRow.forEach((cellMeta) => {
this.hotInstanceUserDataset.setCellMeta(
row,
cellMeta.col as number,
'className',
''
)
})
}
}
onUserInputDatasetsChange() {
return new Promise((resolve, reject) => {
this.helperService.debounceCall(100, () => {
// Parse datasets
const inputDatasets = this.getDatasetsFromHot()
this.matchedDatasets = []
inputDatasets.forEach((dataset: string) => {
const trimmedDataset = dataset.trim()
if (
this.isValidDatasetFormat(trimmedDataset) &&
this.isValidDatasetReference(trimmedDataset) &&
!this.matchedDatasets.includes(trimmedDataset)
) {
this.matchedDatasets.push(trimmedDataset)
} else {
console.warn(
`Sheet name: ${trimmedDataset} is not an actual dataset reference.`
)
}
})
this.cdr.detectChanges()
resolve(undefined)
})
})
}
/**
* Try to find the datasets in the provided file by looking in the
* sheet names and testing if they are in the scope of the dataset naming
* convention. {@link isValidDatasetFormat}
*/
async onAutoDetectColumns() {
// Wait for hotInstanceUserDataset to be ready
if (!this.hotInstanceUserDataset) {
let attempts = 0
const maxAttempts = 20
while (!this.hotInstanceUserDataset && attempts < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, 100))
attempts++
}
if (!this.hotInstanceUserDataset) {
console.warn('hotInstanceUserDataset not ready after waiting')
return
}
}
let passwordError = false
await this.parseExcelSheetNames()
.then((sheetNames) => {
this.sheetNames = sheetNames
})
.catch((err) => {
if (err.includes('password')) {
passwordError = true
}
})
if (passwordError) {
this.onDiscardFile()
this.eventService.showInfoModal(
'Locked file',
'We failed to unlock the file.'
)
return
}
if (this.sheetNames) {
this.matchedDatasets = []
this.userInputDatasets = ''
this.sheetNames.forEach((sheetName: string, index: number) => {
const trimmedSheetname = sheetName.trim()
if (
this.isValidDatasetFormat(trimmedSheetname) &&
this.isValidDatasetReference(trimmedSheetname)
) {
this.matchedDatasets.push(trimmedSheetname)
} else {
console.warn(
`Sheet name: ${trimmedSheetname} is not an actual dataset reference.`
)
}
})
}
// Set matched datasets to textarea, dataset per row
this.userInputDatasets = this.matchedDatasets.join('\n')
const hotReadyData = this.matchedDatasets.map((matchedDs) => {
return [matchedDs.split('.')[0], matchedDs.split('.')[1]]
})
// Add empty rows to fill initial number of rows if data has less rows
// The reason is to fill the height of the table
const initialNumberOfRows = this.hotUserDatasets.data!.length
if (hotReadyData.length < initialNumberOfRows) {
const missingRows = initialNumberOfRows - hotReadyData.length
for (let i = 0; i < missingRows; i++) {
hotReadyData.push(['', ''])
}
}
if (this.hotInstanceUserDataset) {
// Load data manually - this is required for HOT v16 Angular wrapper
setTimeout(() => {
this.hotInstanceUserDataset.loadData(hotReadyData)
this.hotInstanceUserDataset.render()
}, 100)
}
this.dynamicCellValidations()
}
onParsedDatasetClick(parsedDataset: ParsedDataset) {
this.deselectAllParsedDatasets()
parsedDataset.active = true
this.cdr.detectChanges()
this.initHot()
}
onSubmittedCsvDatasetClick(submittedDataset: SubmittedCsvDatasetResult) {
this.deselectAllSubmittedCsvDatasets()
submittedDataset.active = true
}
public get activeParsedDataset(): ParsedDataset | undefined {
return this.parsedDatasets.find((dataset) => dataset.active)
}
public get activeSubmittedCsvDataset():
| SubmittedCsvDatasetResult
| undefined {
return this.submittedCsvDatasets.find((dataset) => dataset.active)
}
public get notFoundDatasets(): string[] {
const userDatasets = this.getDatasetsFromHot()
return userDatasets
.filter((userDs) => !this.matchedDatasets.includes(userDs.trim()))
.filter((userDs) => userDs.length)
}
public get isHotHidden(): boolean {
if (!this.hotInstance) return true
try {
const className = this.hotInstance.getSettings().className
return !!className && className.includes('htCustomHidden')
} catch (err) {
return true
}
}
public get tablesToSubmit(): ParsedDataset[] {
return this.parsedDatasets.filter(
(dataset) =>
dataset.datasource && dataset.parseResult && dataset.includeInSubmission
)
}
public get submittingCsv(): boolean {
return this.csvFiles.length > 0
}
public get excelsSubmitted(): boolean {
return !!this.parsedDatasets.filter((ds) => ds.submitResult).length
}
public downloadFile(response: any) {
const filename = `stagedata-${this.activeSubmittedCsvDataset?.libds}-log`
this.helperService.downloadTextFile(filename, JSON.stringify(response))
}
/**
* Submits attached CSVs which are matched with existing datasets
*/
async submitCsvFiles() {
let requestsResults: SubmittedCsvDatasetResult[] = []
for (let file of this.csvFiles) {
const libds = this.parseDatasetFromCsvName(file.fileName)
let error
let success
await this.sasService
.uploadFile('services/editors/loadfile', [file], { table: libds })
.then(
(res: UploadFileResponse) => {
if (typeof res.adapterResponse.sasjsAbort !== 'undefined') {
error = res.adapterResponse.sasjsAbort
} else {
success = res.adapterResponse
}
},
(err: any) => {
console.error('err', err)
error = err.adapterResponse
}
)
requestsResults.push({
success,
error,
libds: libds
})
}
this.submittedCsvDatasets = requestsResults
}
/**
* Fetches the table for given datasets params LIBRARY.TABLE
*/
async fetchDataset(
libds: string
): Promise<EditorsGetDataServiceResponse | undefined> {
let myParams: any = {
LIBDS: libds,
OUTDEST: 'WEB'
}
if (libds) {
// this.getdataError = false
return this.sasStoreService
.callService(myParams, 'SASControlTable', 'editors/getdata', libds)
.then((res: EditorsGetDataServiceResponse) => {
// getdata serves a selection over DC_MAXOBS_WEBEDIT / DC_MAXCELLS_WEBEDIT
// truncated, rather than aborting the way it used to. This flow compares
// the whole table against the upload, so a partial read would compute
// the changes against the wrong starting state - refuse the table
// instead of submitting differences nobody asked for.
const sasParams = res?.data?.sasparams?.[0]
if (sasParams && Number(sasParams.TRUNCATED) === 1) {
this.eventService.showInfoModal(
'Upload error',
`${libds} holds more than the EDIT limit allows (${sasParams.TRUNC_MSG}), so it cannot be compared with the upload. Raise DC_MAXOBS_WEBEDIT / DC_MAXCELLS_WEBEDIT, or submit the table without the multi-table upload.`
)
return undefined
}
return res
})
.catch((err: any) => {
console.warn(`Error fetching ${libds}`, err)
return undefined
})
}
return undefined
}
/**
* Sends tables found in an excel to the SAS sequentially
*
* @param explicitDatasets if empty all datasets will be sent, otherwise only datasets
* in the array will be sent. eg. ['lib1.table_1', 'lib1.table_2']
*/
async submitTables(explicitDatasets?: string[]) {
console.info('Submitting multiple tables', this.parsedDatasets)
this.submitLoading = true
for (let table of this.parsedDatasets) {
// Skip the table if no data inside
if (!table.parseResult || !table.datasource) continue
// Skip the table if toggle switch is off
if (!table.includeInSubmission) continue
// Skip the table if datasets is present and this table not defined in it
if (explicitDatasets && !explicitDatasets.includes(table.libds)) continue
let updateParams: any = {}
this.submitReasonMessage = this.submitReasonMessage.replace(/\n/g, '. ')
updateParams.ACTION = 'LOAD'
updateParams.MESSAGE = this.submitReasonMessage
updateParams.LIBDS = table.libds
let data = table.datasource
if (data) {
data = data.map((row: any) => {
let deleteColValue = row['_____DELETE__THIS__RECORD_____']
delete row['_____DELETE__THIS__RECORD_____']
row['_____DELETE__THIS__RECORD_____'] = deleteColValue
// If cell is numeric and value is dot `.` we change it to `null`
Object.keys(row).map((key: string) => {
const colRule = table.datasetInfo.dcValidator?.getRule(key)
if (colRule?.type === 'numeric' && row[key] === '.') row[key] = null
})
return row
})
const submitData = data.slice(
0,
this.licenceState.value.submit_rows_limit
)
let error
let success
let log
await this.sasStoreService
.updateTable(
updateParams,
submitData,
'SASControlTable',
'editors/stagedata',
table.datasetInfo.data.$sasdata,
true,
{
debug: true
}
)
.then((res: RequestWrapperResponse<EditorsStageDataSASResponse>) => {
success = res.adapterResponse
log = res.log
})
.catch((err: any) => {
console.error('err', err)
error = err.adapterResponse
log = err.log
})
const requestResult = {
success,
error,
log,
parseResult: table.parseResult,
libds: table.libds
}
table.submitResult = requestResult
}
}
this.showSubmitReasonModal = false
this.submitLoading = false
this.deselectAllParsedDatasets()
}
async reSubmitTable(activeSubmittedDataset: ParsedDataset) {
// Submit only particular table
await this.submitTables([activeSubmittedDataset.libds])
// Activate new resubmitted table
const newSubmittedDataset = this.parsedDatasets.find(
(sd) => sd.libds === activeSubmittedDataset.libds
)
if (newSubmittedDataset) newSubmittedDataset.active = true
}
/**
*
* @returns Promise once workbook is loaded because use XLSX.read in the background
*/
private workbookLoaded(): Promise<XLSX.WorkBook> {
return new Promise((resolve, reject) => {
if (!this.selectedFile) reject('No file selected')
this.workbookInterval = setInterval(() => {
if (this.selectedFile!.workbook) {
clearInterval(this.workbookInterval)
resolve(this.selectedFile!.workbook)
}
}, 500)
})
}
private parseDatasetFromCsvName(fileName: string) {
const fileNameArr = fileName.split('.')
fileNameArr.pop()
return fileNameArr.join('.')
}
/**
*
* @returns list of strings containing datasets in the HOT user input
* Format: LIBRARY.TABLE
*/
private getDatasetsFromHot(): string[] {
if (!this.hotInstanceUserDataset) return []
// getData() returns unknown[][] in HOT 18; see dynamicCellValidations() above.
const hotData = this.hotInstanceUserDataset.getData() as string[][]
return hotData
.filter((row) => row[0]?.length && row[1]?.length)
.map((row) => (row ? `${row[0]}.${row[1]}` : ''))
}
/**
* Read the file minimally just to get the sheet names, not reading full file
* to help boost the performance
*
* @returns sheet names in string array
*/
private async parseExcelSheetNames(): Promise<string[]> {
return new Promise((resolve, reject) => {
if (!this.selectedFile) return resolve([])
this.spreadsheetService
.parseExcelSheetNames(this.selectedFile)
.then((parsed) => {
if (parsed.password) this.selectedFile!.password = parsed.password
return resolve(parsed.sheetNames)
})
.catch((err) => {
return reject(err)
})
})
}
/**
* Valid dataset format includes:
* - Name must contain a single period (.)
* - First part (before period) can be no more than 8 chars
* - Second part (after period) can be no more than 32 chars
* - (start with letter or underscore, and contain only letters / underscores / numbers)
* - can't start with a number
* - both left and right parts must be valid variable names
*
* example: LIB123.TABLE_123
*/
private isValidDatasetFormat(name: string) {
const regex = /^\w{1,8}\.\w{1,32}$/gim
const correctFormat = regex.test(name)
return correctFormat
}
/**
* Checks if @param datasetRef is valid variable which references library and table
*/
private isValidDatasetReference(datasetRef: string) {
const library = datasetRef.split('.')[0]
const table = datasetRef.split('.')[1]
const libTable = this.libsAndTables[library]?.includes(table)
if (libTable) return true
return false
}
/**
* Creates array of objects, containing all `getdata` responses per item
* every object will have headers and validations parsed, to be used for
* parsing sheet file and uploading it
*
* @param response
* @returns
*/
private buildDatasetsObjects(responses: EditorsGetDataServiceResponse[]) {
if (!responses) return []
const datasetObjects: DatasetsObject[] = []
for (let response of responses) {
if (response.data) {
// The delete column is client-owned (see deleteRecordColumnRule.ts):
// asserts getdata did not send it, then prepends it to COLHEADERS so
// it is index 0 for everything below.
prepareDeleteRecordColumn(response)
seedDeleteRecordColumn(response.data.sasdata)
const datasetObject: DatasetsObject = {
...response,
hotTable: {
data: response.data.sasdata,
settings: {}
},
cols: [],
headerColumns: [],
headerPks: [],
headerArray: [],
dateHeaders: [],
timeHeaders: [],
dateTimeHeaders: [],
xlRules: [],
columnHeader: []
}
datasetObject.cols = response.data.cols
datasetObject.headerColumns =
response.data.sasparams[0].COLHEADERS.split(',')
datasetObject.headerPks = response.data.sasparams[0].PK.split(' ')
if (
datasetObject.headerColumns.indexOf(DELETE_RECORD_COLUMN_NAME) !== -1
) {
datasetObject.headerColumns[
datasetObject.headerColumns.indexOf(DELETE_RECORD_COLUMN_NAME)
] = 'Delete?'
}
datasetObject.headerArray = datasetObject.headerColumns.slice(1)
if (response.data.sasparams[0].DTVARS !== '') {
datasetObject.dateHeaders =
response.data.sasparams[0].DTVARS.split(' ')
}
if (response.data.sasparams[0].TMVARS !== '') {
datasetObject.timeHeaders =
response.data.sasparams[0].TMVARS.split(' ')
}
if (response.data.sasparams[0].DTTMVARS !== '') {
datasetObject.dateTimeHeaders =
response.data.sasparams[0].DTTMVARS.split(' ')
}
if (response.data.xl_rules.length > 0) {
datasetObject.xlRules = this.helperService.deepClone(
response.data.xl_rules
)
}
datasetObject.dcValidator = new DcValidator(
response.data.sasparams[0],
response.data.$sasdata,
response.data.cols,
response.data.dqrules,
response.data.dqdata
)
datasetObject.columnHeader =
response.data.sasparams[0].COLHEADERS.split(',')
datasetObjects.push(datasetObject)
}
}
return datasetObjects
}
private deselectAllParsedDatasets() {
for (let parsedDataset of this.parsedDatasets) {
parsedDataset.active = false
}
}
private deselectAllSubmittedCsvDatasets() {
for (let submittedDataset of this.submittedCsvDatasets) {
submittedDataset.active = false
}
}
}
export interface DatasetsObject extends EditorsGetDataServiceResponse {
hotTable: HotTableInterface
cols: Col[]
headerColumns: string[]
headerPks: string[]
headerArray: string[]
dateHeaders: string[]
timeHeaders: string[]
dateTimeHeaders: string[]
xlRules: ExcelRule[]
dcValidator?: DcValidator
columnHeader: string[]
}
export interface ParsedDataset {
libds: string
parseResult?: ParseResult
datasetInfo: DatasetsObject
submitResult?: SubmittedDatasetResult
datasource?: any[]
includeInSubmission: boolean
status?: 'success' | 'error'
active?: boolean
parsingTable?: boolean
}
export interface SubmittedDatasetResult {
success: EditorsStageDataSASResponse | undefined
error: any
log?: string
}
export interface SubmittedCsvDatasetResult {
libds: string
success: EditorsStageDataSASResponse | undefined
error: any
active?: boolean
}
export interface SelectedFile extends File {
sizeMB?: number
password?: string
workbook?: XLSX.WorkBook
}
<app-sidebar>
@if (datasetsLoading) {
<div class="my-10-mx-auto text-center">
<clr-spinner clrMedium></clr-spinner>
</div>
}
@if (!parsedDatasets.length) {
<div class="text-center mb-10">
<button
(click)="fileUploadInput.click()"
id="browse-file"
class="btn btn-primary btn-sm"
[disabled]="selectedFile !== null || submittingCsv"
>
Browse file
</button>
<input
hidden
#fileUploadInput
id="file-upload"
type="file"
(change)="onFileChange($event)"
multiple
/>
</div>
}
@if (parsedDatasets.length && !submittedCsvDatasets.length) {
@if (!excelsSubmitted) {
<div class="text-center mb-10">
<button (click)="onDiscard()" class="btn btn-danger btn-sm mr-10">
Discard
</button>
<button
(click)="onSubmitAll()"
id="submit-all"
class="btn btn-primary btn-sm"
>
Submit All
</button>
</div>
}
<p cds-text="caption" class="ml-10 mb-10">Found tables:</p>
<clr-tree>
@for (dataset of parsedDatasets; track dataset) {
<clr-tree-node>
<button
(click)="onParsedDatasetClick(dataset)"
class="clr-treenode-link whitespace-nowrap d-flex clr-align-items-center"
[class.active]="dataset.active"
>
@if (dataset.submitResult) {
@if (dataset.submitResult.error) {
<cds-icon status="danger" shape="exclamation-circle"></cds-icon>
}
@if (dataset.submitResult.success) {
<cds-icon status="success" shape="check-circle"></cds-icon>
}
}
@if (!dataset.submitResult) {
@if (dataset.datasource) {
@if (!(dataset.datasource.length && dataset.parseResult)) {
<cds-icon
status="danger"
shape="exclamation-circle"
></cds-icon>
}
@if (dataset.datasource.length && dataset.parseResult) {
<cds-icon shape="table"></cds-icon>
}
}
@if (!dataset.datasource) {
@if (!dataset.parsingTable) {
<cds-icon shape="table"></cds-icon>
}
@if (dataset.parsingTable) {
<clr-spinner clrSmall></clr-spinner>
}
}
}
<span class="ml-5"> {{ dataset.libds }} </span>
</button>
</clr-tree-node>
}
</clr-tree>
}
@if (submittedCsvDatasets.length) {
<p cds-text="caption" class="ml-10 mb-10 mt-10">Submitted tables:</p>
<clr-tree>
@for (dataset of submittedCsvDatasets; track dataset) {
<clr-tree-node>
<button
(click)="onSubmittedCsvDatasetClick(dataset)"
class="clr-treenode-link whitespace-nowrap"
[class.active]="dataset.active"
>
@if (dataset.error) {
<cds-icon status="danger" shape="exclamation-circle"></cds-icon>
}
@if (dataset.success) {
<cds-icon status="success" shape="check-circle"></cds-icon>
}
<cds-icon shape="table"></cds-icon>
{{ dataset.libds }}
</button>
</clr-tree-node>
}
</clr-tree>
}
<!-- <div *ngIf="librariesPaging" class="w-100 text-center">
<span class="spinner spinner-sm"> Loading... </span>
</div> -->
</app-sidebar>
<div #contentArea class="content-area">
<div class="card no-borders h-100 d-flex clr-flex-column">
<div
class="header-row clr-row justify-content-between clr-justify-content-center w-100 m-0"
>
<p cds-text="section">Multi Dataset Load</p>
</div>
@if (selectedFile === null && !submittingCsv) {
<div class="no-table-selected pointer-events-none">
<clr-icon
aria-hidden="true"
shape="upload-cloud"
size="40"
class="is-info icon-dc-fill"
></clr-icon>
<p class="text-center color-gray mt-10" cds-text="section">
Please upload a file
</p>
</div>
}
@if (selectedFile !== null || submittingCsv) {
@if (!parsedDatasets.length && selectedFile !== null) {
<div class="d-flex clr-justify-content-center mt-15">
<div class="dataset-input-wrapper">
<p cds-text="secondary regular" class="mb-5">
Selected file: <strong>{{ selectedFile.name }}</strong>
<clr-tooltip>
<cds-icon
clrTooltipTrigger
(click)="onDiscardFile()"
shape="trash"
status="danger"
class="ml-5 cursor-pointer"
></cds-icon>
<clr-tooltip-content> Discard the file </clr-tooltip-content>
</clr-tooltip>
</p>
<p cds-text="secondary regular" class="mb-20">
File size: <strong>{{ selectedFile.sizeMB }} MB</strong>
</p>
<p cds-text="secondary regular" class="mb-15">
Paste or type the list of datasets to upload:
</p>
<clr-control-helper class="mb-5"
>Each row is one dataset. We will automatically detect tables by
the sheetname and populate if any.</clr-control-helper
>
<hot-table
#hotInstanceUserDataset
id="hotTableUserDataset"
class="mt-15"
[settings]="hotUserDatasetsSettings"
>
</hot-table>
<div class="dataset-selection-actions text-right mt-10">
<button
(click)="onStartParsingFile()"
id="continue-btn"
class="btn btn-primary btn-sm"
[disabled]="!matchedDatasets.length"
[clrLoading]="uploadLoading"
>
Continue
</button>
</div>
</div>
</div>
}
@if (parsedDatasets.length && !submittedCsvDatasets.length) {
@if (!activeParsedDataset) {
<div class="no-table-selected pointer-events-none">
@if (fileLoadingState !== FileLoadingState.parsed) {
<clr-icon
aria-hidden="true"
shape="process-on-vm"
size="40"
class="is-info icon-dc-fill"
></clr-icon>
<p class="text-center color-gray mt-10" cds-text="section">
{{ fileLoadingState }}...
</p>
}
@if (fileLoadingState === FileLoadingState.parsed) {
<clr-icon
aria-hidden="true"
shape="warning-standard"
size="40"
class="is-info icon-dc-fill"
></clr-icon>
<p class="text-center color-gray mt-10" cds-text="section">
Please select a dataset on the left to review the data
</p>
}
</div>
}
@if (activeParsedDataset) {
@if (activeParsedDataset.submitResult) {
<div
class="d-flex clr-justify-content-between p-10 mt-15 submission-results"
>
<div>
<p cds-text="secondary regular" class="mb-10">
Submit Status:
@if (activeParsedDataset.submitResult.success) {
<span class="color-green"><strong>SUCCESS</strong></span>
}
@if (activeParsedDataset.submitResult.error) {
<span class="color-red"><strong>ERROR</strong></span>
}
</p>
@if (activeParsedDataset.submitResult.error) {
<p cds-text="secondary regular">Error details:</p>
}
</div>
<div>
@if (!submittingCsv && activeParsedDataset.submitResult.error) {
<button
(click)="reSubmitTable(activeParsedDataset)"
class="btn btn-primary mt-10"
[clrLoading]="submitLoading"
>
Resubmit
</button>
}
<button
(click)="
downloadFile(
activeParsedDataset.submitResult.log ||
activeParsedDataset.submitResult.success ||
activeParsedDataset.submitResult.error
)
"
class="btn btn-primary-outline mt-10"
>
Download log
</button>
</div>
</div>
}
@if (activeParsedDataset.submitResult?.error) {
<div class="error-field mt-15">
<div class="log-wrapper">
{{ activeParsedDataset.submitResult?.error | json }}
</div>
</div>
}
<div class="d-flex clr-justify-content-between p-10 mt-15">
<div>
<p cds-text="secondary regular" class="mb-10">
Found in range:
@if (activeParsedDataset.parseResult) {
<strong
>"{{
activeParsedDataset.parseResult.rangeSheetRes?.sheetName
}}"!{{
activeParsedDataset.parseResult.rangeSheetRes
?.rangeAddress
}}</strong
>
}
@if (!activeParsedDataset.parseResult) {
@if (!activeParsedDataset.parsingTable) {
<strong>No data found</strong>
}
@if (activeParsedDataset.parsingTable) {
<span class="d-flex clr-align-items-center">
<strong>Searching for the data...</strong>
<clr-spinner class="ml-5" clrSmall></clr-spinner>
</span>
}
}
</p>
<p cds-text="secondary regular">
Dataset:
<strong>
<clr-tooltip>
<a
clrTooltipTrigger
[routerLink]="'/editor/' + activeParsedDataset.libds"
>{{ activeParsedDataset.libds }}</a
>
<clr-tooltip-content
[clrPosition]="'top-right'"
[clrSize]="'sm'"
>
Click to edit the table
</clr-tooltip-content>
</clr-tooltip>
</strong>
</p>
</div>
<div>
<clr-toggle-wrapper>
<input
type="checkbox"
clrToggle
[(ngModel)]="activeParsedDataset.includeInSubmission"
name="options"
[disabled]="
!(
activeParsedDataset.datasource &&
activeParsedDataset.parseResult
)
"
required
value="option1"
/>
<label>Include in submission</label>
</clr-toggle-wrapper>
</div>
</div>
@if (isHotHidden) {
<div class="text-center w-100">
<clr-spinner class="spinner-md"></clr-spinner>
</div>
}
<hot-table
#hotInstanceMain
id="hotTable"
class="mt-15"
[settings]="hotMainTableSettings"
>
</hot-table>
}
}
@if (submittedCsvDatasets.length) {
@if (!activeSubmittedCsvDataset) {
<div class="no-table-selected pointer-events-none">
<clr-icon
aria-hidden="true"
shape="warning-standard"
size="40"
class="is-info icon-dc-fill"
></clr-icon>
<p class="text-center color-gray mt-10" cds-text="section">
Please select a dataset on the left to review the submit results
</p>
</div>
}
}
@if (activeSubmittedCsvDataset) {
<div class="d-flex clr-justify-content-between p-10">
<div>
<p cds-text="secondary regular" class="mb-10">
Matched with dataset:
<strong>
<clr-tooltip>
<a
clrTooltipTrigger
[routerLink]="'/editor/' + activeSubmittedCsvDataset.libds"
>{{ activeSubmittedCsvDataset.libds }}</a
>
<clr-tooltip-content
[clrPosition]="'top-right'"
[clrSize]="'sm'"
>
Click to edit the table
</clr-tooltip-content>
</clr-tooltip>
</strong>
</p>
<p cds-text="secondary regular" class="mb-10">
Status:
@if (activeSubmittedCsvDataset.success) {
<span class="color-green"><strong>SUCCESS</strong></span>
}
@if (activeSubmittedCsvDataset.error) {
<span class="color-red"><strong>ERROR</strong></span>
}
</p>
@if (activeSubmittedCsvDataset.error) {
<p cds-text="secondary regular">Error details:</p>
}
</div>
<div>
<button
(click)="
downloadFile(
activeSubmittedCsvDataset.success ||
activeSubmittedCsvDataset.error
)
"
class="btn btn-primary-outline mt-10"
>
Download log
</button>
</div>
</div>
@if (activeSubmittedCsvDataset.error) {
<div class="error-field mt-15">
<div class="log-wrapper">
{{ activeSubmittedCsvDataset.error | json }}
</div>
</div>
}
}
}
<!-- <div>
<p
*ngIf="
licenceState.value.viewer_rows_allowed !== Infinity &&
hotTable.data &&
hotTable.data.length > licenceState.value.viewer_rows_allowed
"
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>
<clr-modal [(clrModalOpen)]="showSubmitReasonModal" [clrModalClosable]="false">
<h3 class="modal-title">
Submit {{ tablesToSubmit.length }}
{{ tablesToSubmit.length === 1 ? 'table' : 'tables' }} for approval
</h3>
<div class="modal-body">
@if (licenceState.value.submit_rows_limit !== Infinity) {
<p cds-text="body" class="licence-limit-notice mt-0 mb-15">
Due to current licence, only
{{ licenceState.value.submit_rows_limit }} rows in each file will be
submitted. To remove the restriction, contact
support@datacontroller.io.
</p>
}
<div class="text-area-full-width">
<label for="formFields_8" class="mb-5 d-block">Message</label>
<textarea
clrTextarea
[(ngModel)]="submitReasonMessage"
tabindex="0"
class="submit-reason"
type="text"
id="formFields_8"
></textarea>
</div>
<p cds-text="caption_clean" class="mt-10">
Tables will be sent sequentially, logs will be available after all tables
are submitted.
</p>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-outline"
[disabled]="submitLoading"
(click)="showSubmitReasonModal = false"
>
Cancel
</button>
<button
type="button"
id="submit-tables"
class="btn btn-primary"
[clrLoading]="submitLoading"
(click)="submitTables()"
>
Submit
</button>
</div>
</clr-modal>
<clr-modal [(clrModalOpen)]="csvSubmitting" [clrModalClosable]="false">
<h3 class="modal-title">
Submitting {{ csvFiles.length }} CSV
{{ csvFiles.length === 1 ? 'file' : 'files' }}
</h3>
<div class="modal-body">
<div class="text-center">
<clr-spinner clrMedium></clr-spinner>
</div>
<p cds-text="caption_clean" class="mt-10 text-center">
This will take few moments
</p>
</div>
</clr-modal>
./multi-dataset.component.scss