feat(multi load): added HOT for user datasets input
Some checks failed
Build / Build-and-ng-test (pull_request) Failing after 51s

This commit is contained in:
Mihajlo Medjedovic 2024-06-20 14:11:20 +02:00
parent 6df7d8d2ba
commit 18363bbbeb
9 changed files with 434 additions and 163 deletions

View File

@ -19,10 +19,11 @@ import {
exclamationTriangleIcon,
fileIcon,
moonIcon,
sunIcon
sunIcon,
trashIcon
} from '@cds/core/icon'
ClarityIcons.addIcons(moonIcon, sunIcon, exclamationTriangleIcon, fileIcon)
ClarityIcons.addIcons(moonIcon, sunIcon, exclamationTriangleIcon, fileIcon, trashIcon)
@Component({
selector: 'my-app',

View File

@ -109,134 +109,169 @@
</p>
</div>
<ng-container *ngIf="!parsedDatasets.length">
<div class="d-flex clr-justify-content-center mt-15">
<div class="dataset-input-wrapper">
<p cds-text="secondary regular" class="mb-20">
Selected file: <strong>{{ selectedFile?.name }}</strong>
</p>
<p cds-text="secondary regular">
Paste or type the list of datasets to upload:
</p>
<button
(click)="onAutoDetectColumns()"
class="mt-15 btn btn-primary-outline btn-sm"
>
Auto detect
</button>
<clr-textarea-container class="m-0">
<textarea
clrTextarea
[(ngModel)]="userInputDatasets"
(input)="onUserInputDatasetsChange()"
class="w-100-i"
></textarea>
<clr-control-helper
>Every row is one dataset. Format:
LIBRARY.TABLE</clr-control-helper
>
</clr-textarea-container>
<div class="text-right mt-10">
<button
(click)="onDiscardFile()"
class="btn btn-danger btn-sm"
[disabled]="uploadLoading"
>
Discard file
</button>
<button
(click)="onUploadFile()"
class="btn btn-primary btn-sm"
[disabled]="!matchedDatasets.length"
[clrLoading]="uploadLoading"
>
Continue
</button>
</div>
<div *ngIf="matchedDatasets.length">
<p><strong>Matched datasets:</strong></p>
<p
*ngFor="let matchedDataset of matchedDatasets"
class="m-0 ml-5-i"
>
{{ matchedDataset }}
<ng-container *ngIf="selectedFile !== null">
<ng-container *ngIf="!parsedDatasets.length">
<div class="d-flex clr-justify-content-center mt-15">
<div class="dataset-input-wrapper">
<p cds-text="secondary regular" class="mb-20">
Selected file: <strong>{{ selectedFile?.name }}</strong>
<cds-icon (click)="onDiscardFile()" shape="trash" status="danger" class="ml-5 cursor-pointer"></cds-icon>
</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 automatically detected some tables by the sheetname.</clr-control-helper>
<hot-table
hotId="hotInstanceUserDataset"
id="hotTableUserDataset"
class="mt-15"
[afterGetColHeader]="afterGetColHeader"
[settings]="hotUserDatasets"
[licenseKey]="hotTableLicenseKey"
stretchH="all"
>
</hot-table>
<div class="dataset-selection-actions text-right mt-10">
<button
(click)="onUploadFile()"
class="btn btn-primary btn-sm"
[disabled]="!matchedDatasets.length"
[clrLoading]="uploadLoading"
>
Continue
</button>
</div>
</div>
</div>
</div>
</ng-container>
</ng-container>
<ng-container *ngIf="parsedDatasets.length">
<div
*ngIf="!activeParsedDataset"
class="no-table-selected pointer-events-none"
>
<clr-icon
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
{{
!submittedDatasets.length
? 'review data'
: 'review submitted results'
}}
</p>
</div>
<ng-container *ngIf="parsedDatasets.length">
<div
*ngIf="!activeParsedDataset && !activeSubmittedDataset"
class="no-table-selected pointer-events-none"
>
<clr-icon
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
{{
!submittedDatasets.length
? 'review data'
: 'review submitted results'
}}
</p>
</div>
<ng-container *ngIf="activeParsedDataset">
<ng-container *ngIf="activeParsedDataset">
<div class="d-flex clr-justify-content-between p-10">
<div>
<p cds-text="secondary regular" class="mb-10">
Found in range:
<strong
>"{{
activeParsedDataset.parseResult.rangeSheetRes?.sheetName
}}"!{{
activeParsedDataset.parseResult.rangeSheetRes?.rangeAddress
}}</strong
>
</p>
<p cds-text="secondary regular">
Matched with dataset:
<strong>{{ activeParsedDataset.libds }}</strong>
</p>
</div>
<div>
<clr-toggle-wrapper>
<input
type="checkbox"
clrToggle
[(ngModel)]="activeParsedDataset.includeInSubmission"
name="options"
required
value="option1"
/>
<label>Include in submission</label>
</clr-toggle-wrapper>
</div>
</div>
<div *ngIf="isHotHidden" class="text-center w-100">
<clr-spinner class="spinner-md"></clr-spinner>
</div>
<hot-table
hotId="hotInstance"
id="hotTable"
class="mt-15"
[afterGetColHeader]="afterGetColHeader"
[className]="['htDark', 'htCustomHidden']"
[licenseKey]="hotTableLicenseKey"
[multiColumnSorting]="true"
[viewportRowRenderingOffset]="50"
[manualColumnResize]="true"
[filters]="true"
stretchH="all"
>
</hot-table>
</ng-container>
</ng-container>
<ng-container *ngIf="activeSubmittedDataset">
<div class="d-flex clr-justify-content-between p-10">
<div>
<p cds-text="secondary regular" class="mb-10">
Found in range:
<strong
>"{{
activeParsedDataset.parseResult.rangeSheetRes?.sheetName
activeSubmittedDataset.parseResult.rangeSheetRes?.sheetName
}}"!{{
activeParsedDataset.parseResult.rangeSheetRes?.rangeAddress
activeSubmittedDataset.parseResult.rangeSheetRes?.rangeAddress
}}</strong
>
</p>
<p cds-text="secondary regular">
<p cds-text="secondary regular" class="mb-10">
Matched with dataset:
<strong>{{ activeParsedDataset.libds }}</strong>
<strong>{{ activeSubmittedDataset.libds }}</strong>
</p>
<p cds-text="secondary regular" class="mb-10">
Status:
<span *ngIf="activeSubmittedDataset.success" class="color-green"><strong>SUCCESS</strong></span>
<span *ngIf="activeSubmittedDataset.error" class="color-red"><strong>ERROR</strong></span>
</p>
<p *ngIf="activeSubmittedDataset.error" cds-text="secondary regular">
Error details:
</p>
</div>
<div>
<clr-toggle-wrapper>
<input
type="checkbox"
clrToggle
[(ngModel)]="activeParsedDataset.includeInSubmission"
name="options"
required
value="option1"
/>
<label>Include in submission</label>
</clr-toggle-wrapper>
<button
(click)="reSubmitTable(activeSubmittedDataset)"
class="btn btn-primary mt-10"
[clrLoading]="submitLoading"
>
Resubmit
</button>
<button
(click)="downloadFile(activeSubmittedDataset.success || activeSubmittedDataset.error)"
class="btn btn-primary-outline mt-10"
>
Download log
</button>
</div>
</div>
<hot-table
hotId="hotInstance"
id="hotTable"
class="mt-15"
className="htDark"
[licenseKey]="hotTableLicenseKey"
[multiColumnSorting]="true"
[viewportRowRenderingOffset]="50"
[manualColumnResize]="true"
[filters]="true"
stretchH="all"
>
</hot-table>
<div *ngIf="activeSubmittedDataset.error" class="error-field mt-15">
<div class="log-wrapper">
{{ activeSubmittedDataset.error | json }}
</div>
</div>
</ng-container>
</ng-container>
@ -274,8 +309,7 @@
</div>
<p cds-text="caption_clean" class="mt-10">
Sheets which did not match any dataset will be ignored. Tables will be
sent sequentially, logs will be available after all tables are submitted.
Tables will be sent sequentially, logs will be available after all tables are submitted.
</p>
</div>
<div class="modal-footer">

View File

@ -17,7 +17,7 @@
}
.dataset-input-wrapper {
max-width: 350px;
max-width: 500px;
width: 100%;
textarea {
@ -30,4 +30,17 @@
min-height: 70px;
max-height: 70px;
height: 70px;
}
.log-wrapper {
margin: 0 10px;
height: auto;
}
::ng-deep td.not-matched {
background-color: #ff000054;
}
.dataset-selection-actions {
border-top: 1px solid #d3d3d3;
}

View File

@ -2,13 +2,13 @@ import {
ChangeDetectorRef,
Component,
HostBinding,
OnInit,
SimpleChanges
OnInit
} from '@angular/core'
import {
EventService,
HelperService,
LicenceService,
SasService,
SasStoreService
} from '../services'
import * as XLSX from '@sheet/crypto'
@ -24,8 +24,9 @@ import {
} from '../services/spreadsheet.service'
import Handsontable from 'handsontable'
import { HotTableRegisterer } from '@handsontable/angular'
import { Router } from '@angular/router'
import { EditorsStageDataSASResponse } from '../models/sas/editors-stagedata.model'
import { CellChange, ChangeSource } from 'handsontable/common'
import { baseAfterGetColHeader } from '../shared/utils/hot.utils'
@Component({
selector: 'app-multi-dataset',
@ -57,11 +58,50 @@ export class MultiDatasetComponent implements OnInit {
} = {}
public hotInstance!: Handsontable
public hotInstanceUserDataset!: Handsontable
private hotRegisterer: HotTableRegisterer
public showSubmitReasonModal: boolean = false
public submitReasonMessage: string = ''
public hotUserDatasets: Handsontable.GridSettings = {
colHeaders: [
'Library',
'Table'
],
data: [
['', ''],
['', ''],
['', ''],
['', ''],
['', ''],
['', ''],
['', ''],
['', ''],
['', ''],
['', ''],
['', ''],
['', '']
],
width: '100%',
height: '305px',
className: ['htDark'],
contextMenu: {
items: {
row_above: {
name: 'Insert Row above'
},
row_below: {
name: 'Insert Row below'
}
}
},
manualRowMove: true,
columnSorting: true
}
public afterGetColHeader = baseAfterGetColHeader
constructor(
private eventService: EventService,
private licenceService: LicenceService,
@ -122,6 +162,9 @@ export class MultiDatasetComponent implements OnInit {
this.selectedFile = event.target.files[0]
event.target.value = '' // Reset the upload input
this.initUserInputHot()
this.onAutoDetectColumns()
}
onDiscardFile() {
@ -197,7 +240,7 @@ export class MultiDatasetComponent implements OnInit {
}
})
.catch((error: string) => {
this.eventService.showInfoModal('Error', error)
console.warn('Parsing excel file error.', error)
})
}
})
@ -217,44 +260,111 @@ export class MultiDatasetComponent implements OnInit {
initHot() {
setTimeout(() => {
if (!this.hotInstance)
this.hotInstance = this.hotRegisterer.getInstance('hotInstance')
this.hotInstance = this.hotRegisterer.getInstance('hotInstance')
if (this.activeParsedDataset) {
this.hotInstance.updateSettings({
data: this.activeParsedDataset.datasetInfo.data.sasdata,
data: this.activeParsedDataset.datasource,
colHeaders: this.activeParsedDataset.datasetInfo.headerColumns,
columns: this.activeParsedDataset.datasetInfo.dcValidator?.getRules(),
readOnly: true,
height: '300px'
height: '300px',
className: 'htDark'
})
}
})
}
onUserInputDatasetsChange() {
this.helperService.debounceCall(500, () => {
const inputDatasets = this.userInputDatasets.split('\n')
initUserInputHot() {
setTimeout(() => {
this.hotInstanceUserDataset = this.hotRegisterer.getInstance('hotInstanceUserDataset')
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.hotInstanceUserDataset.addHook('beforeChange', (changes: (CellChange | null)[], source: ChangeSource) => {
if (changes) {
for (let change of changes) {
if (change && change[3]) {
change[3] = change[3].toUpperCase()
}
}
}
})
console.log('this.matchedDatasets', this.matchedDatasets)
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.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)
}
})
})
}
markUnmatchedRows(row: number) {
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)) {
cellMetaAtRow.forEach(cellMeta => {
this.hotInstanceUserDataset.setCellMeta(row, cellMeta.col, 'className', 'not-matched')
})
} else {
cellMetaAtRow.forEach(cellMeta => {
this.hotInstanceUserDataset.setCellMeta(row, cellMeta.col, 'className', '')
})
}
} else {
cellMetaAtRow.forEach(cellMeta => {
this.hotInstanceUserDataset.setCellMeta(row, cellMeta.col, '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)
})
})
}
@ -286,6 +396,27 @@ export class MultiDatasetComponent implements OnInit {
// 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(['', ''])
}
}
this.hotInstanceUserDataset.updateData(hotReadyData)
}
onParsedDatasetClick(parsedDataset: ParsedDataset) {
@ -294,16 +425,48 @@ export class MultiDatasetComponent implements OnInit {
parsedDataset.active = true
this.cdr.detectChanges()
this.initHot()
}
onSubmittedDatasetClick(submittedDataset: SubmittedDatasetResult) {}
onSubmittedDatasetClick(submittedDataset: SubmittedDatasetResult) {
this.deselectAllSubmittedDatasets()
submittedDataset.active = true
}
public get activeParsedDataset(): ParsedDataset | undefined {
return this.parsedDatasets.find((dataset) => dataset.active)
}
public get activeSubmittedDataset(): SubmittedDatasetResult | undefined {
return this.submittedDatasets.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 downloadFile(
response: any
) {
const filename = `stagedata-${this.activeSubmittedDataset?.libds}-log`
this.helperService.downloadTextFile(filename, JSON.stringify(response))
}
/**
* Fetches the table for given datasets params LIBRARY.TABLE
*/
@ -333,17 +496,22 @@ export class MultiDatasetComponent implements OnInit {
/**
* Sends tables 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() {
async submitTables(explicitDatasets?: string[]) {
console.info('Submitting multiple tables', this.parsedDatasets)
this.submitLoading = true
let requestsResults: SubmittedDatasetResult[] = []
let requestsResults: SubmittedDatasetResult[] = explicitDatasets ? this.submittedDatasets : []
for (let table of this.parsedDatasets) {
// 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 = {}
@ -387,7 +555,8 @@ export class MultiDatasetComponent implements OnInit {
table.datasource,
'SASControlTable',
'editors/stagedata',
table.datasetInfo.data.$sasdata
table.datasetInfo.data.$sasdata,
true
)
.then((res: EditorsStageDataSASResponse) => {
success = res
@ -398,22 +567,53 @@ export class MultiDatasetComponent implements OnInit {
error = err
})
requestsResults.push({
const requestResult = {
success,
error,
parseResult: table.parseResult,
libds: table.libds
})
}
// If explicit datasets are set don't just push to th array
// instead replace if result already exist from before (this might be re-submit)
if (explicitDatasets) {
const existingResultIndex = requestsResults.findIndex(result => result.libds === table.libds)
if (existingResultIndex) {
requestsResults[existingResultIndex] = requestResult
} else {
requestsResults.push(requestResult)
}
} else {
requestsResults.push(requestResult)
}
}
}
console.log('requestsResults', requestsResults)
this.submittedDatasets = requestsResults
this.showSubmitReasonModal = false
this.submitLoading = false
this.deselectAllParsedDatasets()
}
async reSubmitTable(activeSubmittedDataset: SubmittedDatasetResult) {
// Submit only particular table
this.submitTables([activeSubmittedDataset.libds])
}
/**
*
* @returns list of strings containing datasets in the HOT user input
* Format: LIBRARY.TABLE
*/
private getDatasetsFromHot(): string[] {
if (!this.hotInstanceUserDataset) return []
const hotData = this.hotInstanceUserDataset.getData()
return hotData.filter(row => row[0]?.length && row[1]?.length).map(row => row ? `${row[0]}.${row[1]}` : '')
}
private parseExcelSheetNames(): Promise<string[]> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
@ -575,6 +775,12 @@ export class MultiDatasetComponent implements OnInit {
parsedDataset.active = false
}
}
private deselectAllSubmittedDatasets() {
for (let submittedDataset of this.submittedDatasets) {
submittedDataset.active = false
}
}
}
export interface DatasetsObject extends EditorsGetDataServiceResponse {
@ -605,5 +811,6 @@ export interface SubmittedDatasetResult {
libds: string
success: EditorsStageDataSASResponse | undefined
error: any
parseResult: ParseResult
active?: boolean
}

View File

@ -83,7 +83,8 @@ export class SasStoreService {
tableData: any,
tableName: string,
program: string,
$dataFormats: $DataFormats | null
$dataFormats: $DataFormats | null,
suppressErrorSuccessMessages?: boolean
): Promise<EditorsStageDataSASResponse> {
// add sp as third argument of createData call
@ -99,7 +100,10 @@ export class SasStoreService {
tables[tableName] = [tableParams]
let res: any = await this.sasService.request(program, tables)
let res: any = await this.sasService.request(program, tables, null, {
suppressErrorAbortModal: suppressErrorSuccessMessages,
suppressSuccessAbortModal: suppressErrorSuccessMessages
})
return res
}

View File

@ -93,7 +93,8 @@ export class SasService {
* @param url service to run reuqest against
* @param data to be sent to backend service
* @param config additional parameters to force eg. { debug: false }
* @param wrapperOptions used to suppress error or success abort modals after request is finished
* @param wrapperOptions used to provide options to the request wrapper function
* for example to suppress error or success abort modals after request is finished
* @returns
*/
public request(

View File

@ -61,20 +61,6 @@
}
}
.log-wrapper {
min-height: 50px;
padding: 10px;
margin-top: 10px;
white-space: pre-wrap;
border-radius: 3px;
border: 1px solid #e2e2e2;
height: 48vh;
overflow: auto;
}
.no-reqs {
border-top: 1px solid #0000001a;
padding-top: 5px;

View File

@ -0,0 +1,7 @@
/**
* Function reused in HOT instances to add a class used for dark mode
*/
export const baseAfterGetColHeader = (column: number, TH: HTMLTableCellElement, headerLevel: number) => {
// Dark mode
TH.classList.add('darkTH')
}

View File

@ -95,6 +95,20 @@ body[cds-theme="light"] {
}
}
.log-wrapper {
min-height: 50px;
padding: 10px;
margin-top: 10px;
white-space: pre-wrap;
border-radius: 3px;
border: 1px solid #e2e2e2;
height: 48vh;
overflow: auto;
}
// Custom loading spinner
.slider {
position: absolute;
@ -807,6 +821,10 @@ clr-icon.is-info {
box-shadow: none !important;
}
.htCustomHidden {
display: none;
}
body[cds-theme="dark"] {
.htDark {
background: #888;