diff --git a/client/src/app/domain/models/agenda/agenda-item.ts b/client/src/app/domain/models/agenda/agenda-item.ts index c1c5d21233..d808ab2abd 100644 --- a/client/src/app/domain/models/agenda/agenda-item.ts +++ b/client/src/app/domain/models/agenda/agenda-item.ts @@ -36,7 +36,7 @@ export class AgendaItem extends BaseModel { public type!: AgendaItemType; public is_hidden!: boolean; public is_internal!: boolean; - public duration!: number; // in seconds + public duration!: number; // in minutes public weight!: number; /** * Client-calculated field: The level indicates the indentation of an agenda-item. diff --git a/client/src/app/gateways/export/csv-export.service/csv-export-for-backend.service.spec.ts b/client/src/app/gateways/export/csv-export.service/csv-export-for-backend.service.spec.ts new file mode 100644 index 0000000000..e65b54251a --- /dev/null +++ b/client/src/app/gateways/export/csv-export.service/csv-export-for-backend.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { CsvExportForBackendService } from './csv-export-for-backend.service'; + +xdescribe(`CsvExportForBackendService`, () => { + let service: CsvExportForBackendService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(CsvExportForBackendService); + }); + + it(`should be created`, () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/gateways/export/csv-export.service/csv-export-for-backend.service.ts b/client/src/app/gateways/export/csv-export.service/csv-export-for-backend.service.ts new file mode 100644 index 0000000000..9f9b4df802 --- /dev/null +++ b/client/src/app/gateways/export/csv-export.service/csv-export-for-backend.service.ts @@ -0,0 +1,155 @@ +import { Injectable } from '@angular/core'; +import { BaseViewModel } from 'src/app/site/base/base-view-model'; + +import { ExportServiceModule } from '../export-service.module'; +import { FileExportService } from '../file-export.service'; +import { + CsvColumnsDefinition, + DEFAULT_COLUMN_SEPARATOR, + DEFAULT_ENCODING, + DEFAULT_LINE_SEPARATOR, + isMapDefinition, + ISO_8859_15_ENCODING, + isPropertyDefinition +} from './csv-export-utils'; + +@Injectable({ + providedIn: ExportServiceModule +}) +export class CsvExportForBackendService { + public constructor(private exporter: FileExportService) {} + + /** + * Saves an array of model data to a CSV. + * @param models Array of model instances to be saved + * @param columns Column definitions + * @param filename name of the resulting file + * @param options optional: + * lineSeparator (defaults to \r\n windows style line separator), + * columnseparator defaults to configured option (',' , other values are ';', '\t' (tab), ' 'whitespace) + */ + public export( + models: T[], + columns: CsvColumnsDefinition, + filename: string, + { + lineSeparator = DEFAULT_LINE_SEPARATOR, + columnSeparator = DEFAULT_COLUMN_SEPARATOR, + encoding = DEFAULT_ENCODING + }: { + lineSeparator?: string; + columnSeparator?: string; + encoding?: 'utf-8' | 'iso-8859-15'; + } = {} + ): void { + let csvContent = []; // Holds all lines as arrays with each column-value + // initial array of usable text separators. The first character not used + // in any text data or as column separator will be used as text separator + + if (lineSeparator === columnSeparator) { + throw new Error(`lineseparator and columnseparator must differ from each other`); + } + + // create header data + const header = columns.map(column => { + let label: string = ``; + if (isPropertyDefinition(column)) { + label = column.label ? column.label : (column.property as string); + } else if (isMapDefinition(column)) { + label = column.label; + } + return label; + }); + csvContent.push(header); + + // create lines + csvContent = csvContent.concat( + models.map(model => + columns.map(column => { + let value: string = ``; + + if (isPropertyDefinition(column)) { + const property: any = model[column.property]; + if (typeof property === `number`) { + value = property.toString(10); + } else if (!property) { + value = ``; + } else if (property === true) { + value = `1`; + } else if (property === false) { + value = `0`; + } else if (typeof property === `function`) { + const bindedFn = property.bind(model); // bind model to access 'this' + value = bindedFn()?.toString(); + } else { + value = property.toString(); + } + } else if (isMapDefinition(column)) { + value = column.map(model); + } + return this.checkCsvTextSafety(value); + }) + ) + ); + + const csvContentAsString: string = csvContent + .map((line: string[]) => line.join(columnSeparator)) + .join(lineSeparator); + const filetype = `text/csv;charset=${encoding}`; + if (encoding === ISO_8859_15_ENCODING) { + this.exporter.saveFile(this.convertTo8859_15(csvContentAsString), filename, filetype); + } else { + this.exporter.saveFile(csvContentAsString, filename, filetype); + } + } + + public dummyCSVExport(headerAndVerboseNames: I, rows: I[], filename: string): void { + const separator = DEFAULT_COLUMN_SEPARATOR; + const encoding: `utf-8` | `iso-8859-15` = DEFAULT_ENCODING as any; + const headerRow = [Object.keys(headerAndVerboseNames).join(separator)]; + const content = rows.map(row => + Object.keys(headerAndVerboseNames) + .map(key => { + let value = row[key as keyof I] || ``; + if (typeof value === `number`) { + value = value.toString(10); + } else if (typeof value === `boolean`) { + value = value ? `1` : `0`; + } + return this.checkCsvTextSafety(value as string); + }) + .join(separator) + ); + const csv = headerRow.concat(content).join(`\r\n`); + const toExport = encoding === ISO_8859_15_ENCODING ? this.convertTo8859_15(csv) : csv; + this.exporter.saveFile(toExport, filename, `text/csv`); + } + + /** + * Ensures, that the given string has escaped double quotes + * and no linebreak. The string itself will also be escaped by `double quotes`. + * + * @param {string} input any input to be sent to CSV + * @returns {string} the cleaned string. + */ + public checkCsvTextSafety(input: string): string { + if (!input) { + return ``; + } + return `"` + input.replace(/"/g, `""`).replace(/(\r\n\t|\n|\r\t)/gm, ``) + `"`; + } + + /** + * get an iso-8859-15 - compatible blob part + * + * @param data + * @returns a Blob part + */ + private convertTo8859_15(data: string): BlobPart { + const array = new Uint8Array(new ArrayBuffer(data.length)); + for (let i = 0; i < data.length; i++) { + array[i] = data.charCodeAt(i); + } + return array; + } +} diff --git a/client/src/app/gateways/repositories/topics/topic-repository.service.ts b/client/src/app/gateways/repositories/topics/topic-repository.service.ts index 52ff4bd246..aa7c5a87dd 100644 --- a/client/src/app/gateways/repositories/topics/topic-repository.service.ts +++ b/client/src/app/gateways/repositories/topics/topic-repository.service.ts @@ -2,7 +2,9 @@ import { Injectable } from '@angular/core'; import { Identifiable } from 'src/app/domain/interfaces'; import { Topic } from 'src/app/domain/models/topics/topic'; import { ViewAgendaItem, ViewTopic } from 'src/app/site/pages/meetings/pages/agenda'; +import { BackendImportRawPreview } from 'src/app/ui/modules/import-list/definitions/backend-import-preview'; +import { Action } from '../../actions'; import { createAgendaItem } from '../agenda'; import { AgendaItemRepositoryService } from '../agenda/agenda-item-repository.service'; import { BaseAgendaItemAndListOfSpeakersContentObjectRepository } from '../base-agenda-item-and-list-of-speakers-content-object-repository'; @@ -40,6 +42,14 @@ export class TopicRepositoryService extends BaseAgendaItemAndListOfSpeakersConte return this.sendBulkActionToBackend(TopicAction.DELETE, payload); } + public jsonUpload(payload: { [key: string]: any }): Action { + return this.createAction(TopicAction.JSON_UPLOAD, payload); + } + + public import(payload: { id: number; import: boolean }[]): Action { + return this.createAction(TopicAction.IMPORT, payload); + } + public getTitle = (topic: ViewTopic) => topic.title; public override getListTitle = (topic: ViewTopic) => { diff --git a/client/src/app/gateways/repositories/topics/topic.action.ts b/client/src/app/gateways/repositories/topics/topic.action.ts index 46e95ea0a5..76d1314d4a 100644 --- a/client/src/app/gateways/repositories/topics/topic.action.ts +++ b/client/src/app/gateways/repositories/topics/topic.action.ts @@ -2,4 +2,6 @@ export class TopicAction { public static readonly CREATE = `topic.create`; public static readonly UPDATE = `topic.update`; public static readonly DELETE = `topic.delete`; + public static readonly JSON_UPLOAD = `topic.json_upload`; + public static readonly IMPORT = `topic.import`; } diff --git a/client/src/app/site/base/base-import.service/base-backend-import.service.ts b/client/src/app/site/base/base-import.service/base-backend-import.service.ts new file mode 100644 index 0000000000..6d0fed6c34 --- /dev/null +++ b/client/src/app/site/base/base-import.service/base-backend-import.service.ts @@ -0,0 +1,396 @@ +import { Directive } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { TranslateService } from '@ngx-translate/core'; +import { Papa, ParseConfig } from 'ngx-papaparse'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { Identifiable } from 'src/app/domain/interfaces'; +import { FileReaderProgressEvent, ValueLabelCombination } from 'src/app/infrastructure/utils/import/import-utils'; +import { BackendImportService } from 'src/app/ui/base/import-service'; +import { BackendImportPhase } from 'src/app/ui/modules/import-list/components/via-backend-import-list/backend-import-list.component'; +import { + BackendImportPreview, + BackendImportRawPreview, + BackendImportState, + isBackendImportRawPreview +} from 'src/app/ui/modules/import-list/definitions/backend-import-preview'; + +import { ImportServiceCollectorService } from '../../services/import-service-collector.service'; + +@Directive() +export abstract class BaseBackendImportService + implements BackendImportService +{ + /** + * The minimimal number of header entries needed to successfully create an entry + */ + public requiredHeaderLength = 2; + + /** + * The used column separator. If left on an empty string (default), + * the papaparse parser will automatically decide on separators. + */ + public columnSeparator = ``; + + /** + * The used text separator. + */ + public textSeparator = `"`; + + /** + * The encoding used by the FileReader object. + */ + public encoding = `utf-8`; + + /** + * List of possible encodings and their label. values should be values accepted + * by the FileReader API + */ + public encodings: ValueLabelCombination[] = [ + { value: `utf-8`, label: `UTF 8 - Unicode` }, + { value: `iso-8859-1`, label: `ISO 8859-1 - West European` }, + { value: `iso-8859-15`, label: `ISO 8859-15 - West European (with €)` } + ]; + + /** + * List of possible column separators to pass on to papaParse + */ + public columnSeparators: ValueLabelCombination[] = [ + { label: `Comma`, value: `,` }, + { label: `Semicolon`, value: `;` }, + { label: `Automatic`, value: `` } + ]; + + /** + * List of possible text separators to pass on to papaParse. Note that + * it cannot automatically detect textseparators (value must not be an empty string) + */ + public textSeparators: ValueLabelCombination[] = [ + { label: `Double quotes (")`, value: `"` }, + { label: `Single quotes (')`, value: `'` }, + { label: `Gravis (\`)`, value: `\`` } + ]; + + /** + * Observable that allows one to monitor the currenty selected file. + */ + public get rawFileObservable(): Observable { + return this._rawFileSubject; + } + + /** + * Action worker ids that will trigger the currently previewed import. + */ + public get previewActionIds(): number[] { + return this._previewActionIds; + } + + /** + * If false there is something wrong with the data. + */ + public get previewHasRowErrors(): boolean { + return this._previews?.some(preview => preview.state === BackendImportState.Error) ?? false; + } + + /** + * Observable that passes updates on the current preview. + */ + public get previewsObservable(): Observable { + return this._previewsSubject as Observable; + } + + /** + * Observable that allows one to monitor the part of the import that is currently in process. + */ + public get currentImportPhaseObservable(): Observable { + return this._currentImportPhaseSubject as Observable; + } + + /** + * List of possible errors and their verbose explanation. + */ + protected abstract readonly errorList: { [errorKey: string]: string }; + + /** + * List of possible errors and their verbose explanation. + */ + private readonly verboseGeneralErrors: { [errorKey: string]: string } = { + [`Duplicate`]: `Is a duplicate` + }; + + protected readonly translate: TranslateService = this.importServiceCollector.translate; + protected readonly matSnackbar: MatSnackBar = this.importServiceCollector.matSnackBar; + + /** + * Overwrite in subclass to define verbose titles for the ones sent by the backend + */ + protected readonly verboseSummaryTitles: { [title: string]: string } = {}; + + private set previews(preview: BackendImportPreview[] | null) { + this._previews = preview; + this._previewActionIds = this._previews?.map(result => result.id) ?? []; + this._previewsSubject.next(preview); + } + + private _previews: BackendImportPreview[] | null = null; + + private _previewsSubject = new BehaviorSubject(null); + + private _previewActionIds: number[] = []; + + /** + * The last parsed file object (may be reparsed with new encoding, thus kept in memory) + */ + private _rawFile: File | null = null; + + private _rawFileSubject = new BehaviorSubject(null); + + /** + * FileReader object for file import + */ + private _reader = new FileReader(); + + private _currentImportPhaseSubject = new BehaviorSubject(BackendImportPhase.LOADING_PREVIEW); + + /** + * the list of parsed models that have been extracted from the opened file or inserted manually + */ + private _csvLines: { [header: string]: string }[] = []; + private _receivedHeaders: string[] = []; + private readonly _papa: Papa = this.importServiceCollector.papa; + + /** + * Constructor. Creates a fileReader to subscribe to it for incoming parsed + * strings + */ + public constructor(private importServiceCollector: ImportServiceCollectorService) { + this._reader.onload = (event: FileReaderProgressEvent) => { + this.parseInput(event.target?.result as string); + }; + } + + /** + * Parses the data input. Expects a string as returned by via a File.readAsText() operation + * + * @param file + */ + public parseInput(file: string): void { + this.clearPreview(); + const papaConfig: ParseConfig = { + header: true, + skipEmptyLines: `greedy`, + quoteChar: this.textSeparator, + transform: (value, columnOrHeader) => (!value ? undefined : value) + }; + if (this.columnSeparator) { + papaConfig.delimiter = this.columnSeparator; + } + const result = this._papa.parse(file, papaConfig); + this._csvLines = result.data; + this.parseCsvLines(); + } + + /** + * Clears the current File (and the preview along with it) + */ + public clearFile(): void { + this.clearPreview(); + this._rawFile = null; + this._rawFileSubject.next(null); + } + + /** + * Adds new csv lines onto the end of and then updates the preview. + * + * @param lines should conform to the format expected by the backend. + */ + public addLines(...lines: { [header: string]: any }[]): void { + for (const line of lines) { + this._csvLines.push(line); + } + this.parseCsvLines(); + } + + /** + * Handler after a file was selected. Basic checking for type, then hand + * over to parsing + * + * @param event type is Event, but has target.files, which typescript doesn't seem to recognize + */ + public onSelectFile(event: any): void { + if (event.target.files && event.target.files.length === 1) { + this._rawFile = event.target.files[0]; + this._rawFileSubject.next(this._rawFile); + this.readFile(); + } + } + + /** + * Rereads the (previously selected) file, if present. Thought to be triggered + * by parameter changes on encoding, column, text separators + */ + public refreshFile(): void { + if (this._rawFile) { + this.readFile(); + } + } + + /** + * Resets the data and preview (triggered upon selecting an invalid file) + */ + public clearPreview(): void { + if (this.previewActionIds?.length && this._currentImportPhaseSubject.value !== BackendImportPhase.FINISHED) { + this.import(this.previewActionIds, true); // Delete the suspended action worker from the backend + } + this._currentImportPhaseSubject.next(BackendImportPhase.LOADING_PREVIEW); + this.previews = null; + } + + /** + * Resets the service to starting condition. + */ + public clearAll(): void { + this._csvLines = []; + this.clearFile(); + } + + /** + * Get an extended error description. + * + * @param error + * @returns the extended error desription for that error + */ + public verbose(error: string): string { + return this.errorList[error] || this.verboseGeneralErrors[error] || error; + } + + /** + * Matches the summary titles from the backend to more verbose versions that should be displayed instead. + */ + public getVerboseSummaryPointTitle(title: string): string { + const verbose = (this.verboseSummaryTitles[title] ?? title).trim(); + return verbose.charAt(0).toUpperCase() + verbose.slice(1); + } + + /** + * Executing the import. + * @returns true if the import finished successfully + */ + public async doImport(): Promise { + this._currentImportPhaseSubject.next(BackendImportPhase.IMPORTING); + + const results = await this.import(this.previewActionIds, false); + + if (Array.isArray(results) && results.find(result => isBackendImportRawPreview(result))) { + const updatedPreviews = results.filter(result => + isBackendImportRawPreview(result) + ) as BackendImportRawPreview[]; + this.processRawPreviews(updatedPreviews); + if (this.previewHasRowErrors) { + this._currentImportPhaseSubject.next(BackendImportPhase.ERROR); + } else { + this._currentImportPhaseSubject.next(BackendImportPhase.TRY_AGAIN); + } + } else { + this._currentImportPhaseSubject.next(BackendImportPhase.FINISHED); + this._csvLines = []; + return true; + } + return false; + } + + /** + * Calculates the payload for the jsonUpload function. + * Should be overridden by sub-classes if the upload needs to be more specific. + * F.e. if it is a meeting import and a meeting id needs to be given additionally + */ + protected calculateJsonUploadPayload(): { [key: string]: any } { + return { + data: this._csvLines + }; + } + + private parseCsvLines(): void { + this._receivedHeaders = Object.keys(this._csvLines[0]); + const isValid = this.checkHeaderLength(); + if (!isValid) { + return; + } + this.propagateNextNewEntries(); + } + + /** + * reads the _rawFile + */ + private readFile(): void { + if (this._rawFile) { + this._reader.readAsText(this._rawFile, this.encoding); + } + } + + /** + * Checks the first line of the csv (the header) for consistency (length) + * + * @returns true if the line has at least the minimum amount of columns + */ + private checkHeaderLength(): boolean { + const snackbarDuration = 3000; + if (this._receivedHeaders.length < this.requiredHeaderLength) { + this.matSnackbar.open(this.translate.instant(`The file has too few columns to be parsed properly.`), ``, { + duration: snackbarDuration + }); + + this.clearPreview(); + return false; + } + return true; + } + + private async propagateNextNewEntries(): Promise { + this.clearPreview(); + const payload = this.calculateJsonUploadPayload(); + const response = (await this.jsonUpload(payload)) as BackendImportRawPreview[]; + if (!response) { + throw new Error(`Didn't receive preview`); + } + this.processRawPreviews(response); + if (this.previewHasRowErrors) { + this._currentImportPhaseSubject.next(BackendImportPhase.ERROR); + } else { + this._currentImportPhaseSubject.next(BackendImportPhase.AWAITING_CONFIRM); + } + } + + private processRawPreviews(rawPreviews: BackendImportRawPreview[]): void { + const previews: (BackendImportRawPreview | BackendImportPreview)[] = rawPreviews; + let index = 1; + for (let preview of previews) { + for (let row of preview.rows) { + row[`id`] = index; + index++; + } + } + + this.previews = previews as BackendImportPreview[]; + } + + // Abstract methods + + /** + * Allows the user to download an example csv file. + */ + public abstract downloadCsvExample(): void; + /** + * Calls the relevant json_upload backend action with the payload. + */ + protected abstract jsonUpload(payload: { [key: string]: any }): Promise; + /** + * Calls the relevant import backend action with the payload. + * + * If abort is set to true, the import should be aborted, + * i.e. the data should NOT be imported, but instead the action worker should be deleted. + */ + protected abstract import( + actionWorkerIds: number[], + abort?: boolean + ): Promise; +} diff --git a/client/src/app/site/base/base-via-backend-import-list.component.ts b/client/src/app/site/base/base-via-backend-import-list.component.ts new file mode 100644 index 0000000000..7a3474e29c --- /dev/null +++ b/client/src/app/site/base/base-via-backend-import-list.component.ts @@ -0,0 +1,91 @@ +import { Directive, OnInit, ViewChild } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { BaseComponent } from 'src/app/site/base/base.component'; +import { + BackendImportListComponent, + BackendImportPhase +} from 'src/app/ui/modules/import-list/components/via-backend-import-list/backend-import-list.component'; + +import { Identifiable } from '../../domain/interfaces'; +import { getLongPreview, getShortPreview } from '../../infrastructure/utils'; +import { ComponentServiceCollectorService } from '../services/component-service-collector.service'; +import { BaseBackendImportService } from './base-import.service/base-backend-import.service'; + +@Directive() +export abstract class BaseViaBackendImportListComponent + extends BaseComponent + implements OnInit +{ + @ViewChild(BackendImportListComponent) + private list: BackendImportListComponent; + + /** + * Helper function for previews + */ + public getLongPreview = getLongPreview; + + /** + * Helper function for previews + */ + public getShortPreview = getShortPreview; + + /** + * True if the import is in a state in which an import can be conducted + */ + public get canImport(): boolean { + return this._state === BackendImportPhase.AWAITING_CONFIRM || this.tryAgain; + } + + /** + * True if the import has successfully finished. + */ + public get finishedSuccessfully(): boolean { + return this._state === BackendImportPhase.FINISHED; + } + + /** + * True if, after an attempted import failed, the view is waiting for the user to confirm the import on the new preview. + */ + public get tryAgain(): boolean { + return this._state === BackendImportPhase.TRY_AGAIN; + } + + /** + * True while an import is in progress. + */ + public get isImporting(): boolean { + return this._state === BackendImportPhase.IMPORTING; + } + + /** + * True if the preview can not be imported. + */ + public get hasErrors(): boolean { + return this._state === BackendImportPhase.ERROR; + } + + private _state: BackendImportPhase = BackendImportPhase.LOADING_PREVIEW; + + public constructor( + componentServiceCollector: ComponentServiceCollectorService, + protected override translate: TranslateService, + protected importer: BaseBackendImportService + ) { + super(componentServiceCollector, translate); + } + + public ngOnInit(): void { + this.importer.currentImportPhaseObservable.subscribe(phase => { + this._state = phase; + }); + } + + /** + * Triggers the importer's import + */ + public async doImport(): Promise { + if (await this.importer.doImport()) { + this.list.removeSelectedFile(false); + } + } +} diff --git a/client/src/app/site/pages/meetings/pages/agenda/modules/topics/definitions/topics.constants.ts b/client/src/app/site/pages/meetings/pages/agenda/modules/topics/definitions/topics.constants.ts index 72cd03d3bb..e50f3f5f6e 100644 --- a/client/src/app/site/pages/meetings/pages/agenda/modules/topics/definitions/topics.constants.ts +++ b/client/src/app/site/pages/meetings/pages/agenda/modules/topics/definitions/topics.constants.ts @@ -3,7 +3,7 @@ import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; export const topicHeadersAndVerboseNames = { title: _(`Title`), text: _(`Text`), - agenda_duration: _(`Duration`), + agenda_duration: _(`Duration in minutes`), agenda_comment: _(`Comment`), - agenda_type: _(`Internal item`) + agenda_type: _(`Visibility on agenda`) }; diff --git a/client/src/app/site/pages/meetings/pages/agenda/modules/topics/pages/topic-import/components/topic-import/topic-import.component.html b/client/src/app/site/pages/meetings/pages/agenda/modules/topics/pages/topic-import/components/topic-import/topic-import.component.html index 7741791be2..5e9b79a31b 100644 --- a/client/src/app/site/pages/meetings/pages/agenda/modules/topics/pages/topic-import/components/topic-import/topic-import.component.html +++ b/client/src/app/site/pages/meetings/pages/agenda/modules/topics/pages/topic-import/components/topic-import/topic-import.component.html @@ -6,16 +6,23 @@

{{ 'Import topics' | translate }}

- @@ -45,13 +52,4 @@

{{ 'Import topics' | translate }}

- -
-
{{ 'Duration' | translate }}
- {{ getDuration(entry.newEntry.agenda_duration) }} -
-
-
{{ 'Internal item' | translate }}
- {{ getTypeString(entry.newEntry.agenda_type) | translate }} -
-
+ diff --git a/client/src/app/site/pages/meetings/pages/agenda/modules/topics/pages/topic-import/components/topic-import/topic-import.component.ts b/client/src/app/site/pages/meetings/pages/agenda/modules/topics/pages/topic-import/components/topic-import/topic-import.component.ts index 0cd20216c9..29f995b078 100644 --- a/client/src/app/site/pages/meetings/pages/agenda/modules/topics/pages/topic-import/components/topic-import/topic-import.component.ts +++ b/client/src/app/site/pages/meetings/pages/agenda/modules/topics/pages/topic-import/components/topic-import/topic-import.component.ts @@ -3,7 +3,7 @@ import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { TranslateService } from '@ngx-translate/core'; import { AgendaItemType, ItemTypeChoices } from 'src/app/domain/models/agenda/agenda-item'; import { Topic } from 'src/app/domain/models/topics/topic'; -import { BaseImportListComponent } from 'src/app/site/base/base-import-list.component'; +import { BaseViaBackendImportListComponent } from 'src/app/site/base/base-via-backend-import-list.component'; import { ComponentServiceCollectorService } from 'src/app/site/services/component-service-collector.service'; import { DurationService } from 'src/app/site/services/duration.service'; import { ImportListHeaderDefinition } from 'src/app/ui/modules/import-list'; @@ -18,19 +18,20 @@ const TEXT_IMPORT_TAB_INDEX = 0; templateUrl: `./topic-import.component.html`, styleUrls: [`./topic-import.component.scss`] }) -export class TopicImportComponent extends BaseImportListComponent { +export class TopicImportComponent extends BaseViaBackendImportListComponent { /** * A form for text input */ public textAreaForm: UntypedFormGroup; - public possibleFields = Object.values(topicHeadersAndVerboseNames); + public possibleFields = Object.keys(topicHeadersAndVerboseNames); public columns: ImportListHeaderDefinition[] = Object.keys(topicHeadersAndVerboseNames).map(header => ({ property: header, label: (topicHeadersAndVerboseNames)[header], isTableColumn: true, - isRequired: header === `title` + isRequired: header === `title`, + flexible: [`title`, `text`, `agenda_comment`].includes(header) })); public get isTextImportSelected(): boolean { @@ -65,7 +66,11 @@ export class TopicImportComponent extends BaseImportListComponent { * Sends the data in the text field input area to the importer */ public parseTextArea(): void { - (this.importer as TopicImportService).parseTextArea(this.textAreaForm.get(`inputtext`)!.value); + const text = this.textAreaForm.get(`inputtext`)!.value; + if (text) { + (this.importer as TopicImportService).parseTextArea(text); + this.textAreaForm.reset(); + } } /** diff --git a/client/src/app/site/pages/meetings/pages/agenda/modules/topics/pages/topic-import/services/topic-export.service/topic-export.service.ts b/client/src/app/site/pages/meetings/pages/agenda/modules/topics/pages/topic-import/services/topic-export.service/topic-export.service.ts index 616a0a4c9a..c048f71277 100644 --- a/client/src/app/site/pages/meetings/pages/agenda/modules/topics/pages/topic-import/services/topic-export.service/topic-export.service.ts +++ b/client/src/app/site/pages/meetings/pages/agenda/modules/topics/pages/topic-import/services/topic-export.service/topic-export.service.ts @@ -21,9 +21,9 @@ export class TopicExportService { public downloadCsvImportExample(): void { const rows: TopicExport[] = [ - { title: `Demo 1`, text: `Demo text 1`, agenda_duration: `1:00`, agenda_comment: `Test comment` }, - { title: `Break`, agenda_duration: `0:10`, agenda_type: `internal` }, - { title: `Demo 2`, text: `Demo text 2`, agenda_duration: `1:30`, agenda_type: `hidden` } + { title: `Demo 1`, text: `Demo text 1`, agenda_duration: `60`, agenda_comment: `Test comment` }, + { title: `Break`, agenda_duration: `10`, agenda_type: `internal` }, + { title: `Demo 2`, text: `Demo text 2`, agenda_duration: `90`, agenda_type: `hidden` } ]; this.csvExportService.dummyCSVExport( diff --git a/client/src/app/site/pages/meetings/pages/agenda/modules/topics/pages/topic-import/services/topic-import.service/topic-import.service.ts b/client/src/app/site/pages/meetings/pages/agenda/modules/topics/pages/topic-import/services/topic-import.service/topic-import.service.ts index 64442963dd..90e74445e2 100644 --- a/client/src/app/site/pages/meetings/pages/agenda/modules/topics/pages/topic-import/services/topic-import.service/topic-import.service.ts +++ b/client/src/app/site/pages/meetings/pages/agenda/modules/topics/pages/topic-import/services/topic-import.service/topic-import.service.ts @@ -1,21 +1,20 @@ import { Injectable } from '@angular/core'; import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; -import { AgendaItemType, ItemTypeChoices } from 'src/app/domain/models/agenda/agenda-item'; +import { AgendaItemType } from 'src/app/domain/models/agenda/agenda-item'; import { Topic } from 'src/app/domain/models/topics/topic'; import { TopicRepositoryService } from 'src/app/gateways/repositories/topics/topic-repository.service'; -import { ImportConfig } from 'src/app/infrastructure/utils/import/import-utils'; -import { BaseImportService } from 'src/app/site/base/base-import.service'; -import { DurationService } from 'src/app/site/services/duration.service'; +import { BaseBackendImportService } from 'src/app/site/base/base-import.service/base-backend-import.service'; +import { ActiveMeetingIdService } from 'src/app/site/pages/meetings/services/active-meeting-id.service'; import { ImportServiceCollectorService } from 'src/app/site/services/import-service-collector.service'; +import { BackendImportRawPreview } from 'src/app/ui/modules/import-list/definitions/backend-import-preview'; -import { topicHeadersAndVerboseNames } from '../../../../definitions'; import { TopicExportService } from '../topic-export.service'; import { TopicImportServiceModule } from '../topic-import-service.module'; @Injectable({ providedIn: TopicImportServiceModule }) -export class TopicImportService extends BaseImportService { +export class TopicImportService extends BaseBackendImportService { /** * The minimimal number of header entries needed to successfully create an entry */ @@ -29,6 +28,14 @@ export class TopicImportService extends BaseImportService { ParsingErrors: _(`Some csv values could not be read correctly.`) }; + public override readonly verboseSummaryTitles: { [title: string]: string } = { + total: _(`Total topics`), + created: _(`Topics created`), + updated: _(`Topics updated`), + omitted: _(`Topics skipped`), + warning: _(`Topics with warnings (will be skipped)`) + }; + /** * Constructor. Calls the abstract class and sets the expected header * @@ -37,9 +44,9 @@ export class TopicImportService extends BaseImportService { */ public constructor( serviceCollector: ImportServiceCollectorService, - private durationService: DurationService, private repo: TopicRepositoryService, - private exporter: TopicExportService + private exporter: TopicExportService, + private activeMeetingId: ActiveMeetingIdService ) { super(serviceCollector); } @@ -48,52 +55,21 @@ export class TopicImportService extends BaseImportService { this.exporter.downloadCsvImportExample(); } - protected getConfig(): ImportConfig { - return { - modelHeadersAndVerboseNames: topicHeadersAndVerboseNames, - verboseNameFn: plural => this.repo.getVerboseName(plural), - getDuplicatesFn: (entry: Partial) => - this.repo.getViewModelList().filter(topic => topic.title === entry.title), - createFn: (entries: any[]) => this.repo.create(...entries) - }; + protected override calculateJsonUploadPayload(): any { + let payload = super.calculateJsonUploadPayload(); + payload[`meeting_id`] = this.activeMeetingId.meetingId; + return payload; } - protected override pipeParseValue(value: string, header: any): any { - if (header === `agenda_duration`) { - return this.parseDuration(value); - } - if (header === `agenda_type`) { - return this.parseType(value); - } - } - - /** - * Matching the duration string/number to the time model in use - * - * @param input - * @returns duration as defined in durationService - */ - public parseDuration(input: string): number { - return this.durationService.stringToDuration(input); + protected async import( + actionWorkerIds: number[], + abort: boolean = false + ): Promise { + return await this.repo.import(actionWorkerIds.map(id => ({ id, import: !abort }))).resolve(); } - /** - * Converts information from 'item type' to a model-based type number. - * Accepts either old syntax (numbers) or new visibility choice csv names; - * both defined in {@link itemVisibilityChoices} - * Empty values will be interpreted as default 'public' agenda topics - * - * @param input - * @returns a number as defined for the itemVisibilityChoices - */ - public parseType(input: string | number): AgendaItemType { - if (typeof input === `string`) { - const visibility = ItemTypeChoices.find(choice => choice.csvName === input); - if (visibility) { - return visibility.key; - } - } - return AgendaItemType.COMMON; // default, public item + protected async jsonUpload(payload: { [key: string]: any }): Promise { + return await this.repo.jsonUpload(payload).resolve(); } /** diff --git a/client/src/app/site/pages/meetings/pages/agenda/modules/topics/pages/topic-import/topic-import.module.ts b/client/src/app/site/pages/meetings/pages/agenda/modules/topics/pages/topic-import/topic-import.module.ts index 0bf19b363a..e20836b3cb 100644 --- a/client/src/app/site/pages/meetings/pages/agenda/modules/topics/pages/topic-import/topic-import.module.ts +++ b/client/src/app/site/pages/meetings/pages/agenda/modules/topics/pages/topic-import/topic-import.module.ts @@ -2,11 +2,14 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; +import { MatTooltipModule } from '@angular/material/tooltip'; import { RouterModule } from '@angular/router'; import { OpenSlidesTranslationModule } from 'src/app/site/modules/translations'; import { HeadBarModule } from 'src/app/ui/modules/head-bar'; import { ImportListModule } from 'src/app/ui/modules/import-list'; +import { SpinnerModule } from 'src/app/ui/modules/spinner'; import { TopicImportComponent } from './components/topic-import/topic-import.component'; import { TopicImportMainComponent } from './components/topic-import-main/topic-import-main.component'; @@ -24,6 +27,9 @@ import { TopicImportRoutingModule } from './topic-import-routing.module'; OpenSlidesTranslationModule.forChild(), MatFormFieldModule, MatInputModule, + MatIconModule, + MatTooltipModule, + SpinnerModule, ReactiveFormsModule, RouterModule ] diff --git a/client/src/app/site/pages/meetings/pages/agenda/pages/agenda-item-list/services/agenda-item-export.service/agenda-item-export.service.ts b/client/src/app/site/pages/meetings/pages/agenda/pages/agenda-item-list/services/agenda-item-export.service/agenda-item-export.service.ts index d7e061dff7..c26e22c7c8 100644 --- a/client/src/app/site/pages/meetings/pages/agenda/pages/agenda-item-list/services/agenda-item-export.service/agenda-item-export.service.ts +++ b/client/src/app/site/pages/meetings/pages/agenda/pages/agenda-item-list/services/agenda-item-export.service/agenda-item-export.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; -import { CsvExportService } from 'src/app/gateways/export/csv-export.service'; +import { CsvExportForBackendService } from 'src/app/gateways/export/csv-export.service/csv-export-for-backend.service'; import { OSTreeNode } from 'src/app/infrastructure/definitions/tree'; import { ViewAgendaItem } from 'src/app/site/pages/meetings/pages/agenda'; import { MeetingPdfExportService } from 'src/app/site/pages/meetings/services/export'; @@ -22,7 +22,7 @@ interface AgendaTreePdfEntry { export class AgendaItemExportService { constructor( private translate: TranslateService, - private csvExportService: CsvExportService, + private csvExportService: CsvExportForBackendService, private pdfExportService: MeetingPdfExportService, private treeService: TreeService ) {} @@ -31,16 +31,15 @@ export class AgendaItemExportService { this.csvExportService.export( source, [ - { label: `Title`, map: viewItem => viewItem.getTitle() }, + { label: `title`, map: viewItem => viewItem.getTitle() }, { - label: `Text`, + label: `text`, map: viewItem => viewItem.content_object?.getCSVExportText ? viewItem.content_object.getCSVExportText() : `` }, - { label: `Duration`, property: `duration` }, - { label: `Comment`, property: `comment` }, - { label: `Item type`, property: `verboseCsvType` }, - { label: `Tags`, property: `tags` } + { label: `agenda_duration`, property: `duration` }, + { label: `agenda_comment`, property: `comment` }, + { label: `agenda_type`, property: `verboseCsvType` } ], this.translate.instant(`Agenda`) + `.csv` ); diff --git a/client/src/app/ui/base/import-service.ts b/client/src/app/ui/base/import-service.ts index e99b2736bd..a79af78561 100644 --- a/client/src/app/ui/base/import-service.ts +++ b/client/src/app/ui/base/import-service.ts @@ -4,6 +4,9 @@ import { ImportModel } from 'src/app/infrastructure/utils/import/import-model'; import { ImportStep } from 'src/app/infrastructure/utils/import/import-step'; import { ValueLabelCombination } from 'src/app/infrastructure/utils/import/import-utils'; +import { BackendImportPhase } from '../modules/import-list/components/via-backend-import-list/backend-import-list.component'; +import { BackendImportPreview } from '../modules/import-list/definitions/backend-import-preview'; + interface ImportServicePreview { new: number; done: number; @@ -38,3 +41,27 @@ export interface ImportService { setNewHeaderValue(headerDefinition: { [headerKey: string]: string }): void; downloadCsvExample(): void; } + +export interface BackendImportService { + readonly rawFileObservable: Observable; + readonly encodings: ValueLabelCombination[]; + readonly columnSeparators: ValueLabelCombination[]; + readonly textSeparators: ValueLabelCombination[]; + readonly previewsObservable: Observable; + readonly currentImportPhaseObservable: Observable; + readonly previewHasRowErrors: boolean; + + columnSeparator: string; + textSeparator: string; + encoding: string; + + verbose(error: string): string; + refreshFile(): void; + clearPreview(): void; + clearFile(): void; + onSelectFile(event: any): void; + doImport(): Promise; + downloadCsvExample(): void; + getVerboseSummaryPointTitle(title: string): string; + clearAll(): void; +} diff --git a/client/src/app/ui/modules/import-list/components/via-backend-import-list/backend-import-list.component.html b/client/src/app/ui/modules/import-list/components/via-backend-import-list/backend-import-list.component.html new file mode 100644 index 0000000000..d684780a50 --- /dev/null +++ b/client/src/app/ui/modules/import-list/components/via-backend-import-list/backend-import-list.component.html @@ -0,0 +1,286 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

{{ 'Summary' | translate }}

+ +
+ + + + + +
{{ getSummaryPointTitle(point.name) | translate }}: {{ point.value }}
+
+ + {{ + 'The import has failed due to recent changes on the server. Please check the updated preview before clicking on "import" again.' + | translate + }} + + + {{ 'After verifying the preview click on "import" please (see top right).' | translate }} + + {{ 'The import is in progress, please wait...' | translate }} + + {{ + 'The import can not proceed. There is likely a problem with the import data, please check the preview for details.' + | translate + }} + + {{ 'The import was successful.' | translate }} +
+ + +

{{ 'Preview' | translate }}

+
+ +
+
+
+
+ + +
+ + {{ + 'Required comma or semicolon separated values with these column header names in the first row:' | translate + }} + +
+
+ + {{ entry }} + + + info +
+
    +
  • + + {{ field }} + + + {{ 'is required' | translate }}. + {{ 'are required' | translate }}. + {{ 'All other fields are optional and may be empty.' | translate }} +
  • +
  • + {{ + 'Additional columns after the required ones may be present and will not affect the import.' | translate + }} +
  • +
+

+ {{ additionalInfo }} +

+ +
+ + {{ 'Encoding of the file' | translate }} + + + {{ option.label | translate }} + + + + + {{ 'Column separator' | translate }} + + + {{ option.label | translate }} + + + + + {{ 'Text separator' | translate }} + + + {{ option.label | translate }} + + + +
+
+
+ + +
+ {{ file.name }} + +
+
+
+
+ + +
+ + + +
+{{ entry[def].length - 1 }}
+
+
+ +
+
+ + + + + + {{ getActionIcon(entry.info) }} + + + + + + + {{ entry | translate }} + {{ entry }} + {{ entry }} + +   + + + + +
+ + {{ getActionIcon(row) }} + +
+
+
#
+ {{ value }} +
+ + +
+
{{ getColumnLabel(column.property) | translate }}
+ +
+
+
+
+ + +
+

{{ 'Preview' | translate }}

+ +
+
+ +
+
+ + +
+

The fields are defined as follows

+
+
+ + + + + +
{{ column.property }}: {{ column.label | translate }}
+
+
+ +
+
diff --git a/client/src/app/ui/modules/import-list/components/via-backend-import-list/backend-import-list.component.scss b/client/src/app/ui/modules/import-list/components/via-backend-import-list/backend-import-list.component.scss new file mode 100644 index 0000000000..860ebe7aba --- /dev/null +++ b/client/src/app/ui/modules/import-list/components/via-backend-import-list/backend-import-list.component.scss @@ -0,0 +1,71 @@ +@import 'src/assets/styles/tables.scss'; + +:host { + --os-row-height: 50px; +} + +mat-card.import-table { + .code { + span { + display: inline-flex; + } + } +} + +pbl-ngrid-header-cell, +pbl-ngrid-cell { + padding-right: 12px; + + &:first-of-type { + padding-left: 12px !important; + } +} + +.import-list-wrapper { + display: grid; + grid-template-columns: auto auto auto; + column-gap: 12px; +} + +.import-list-summary { + .import-list-icon { + animation: rotation 1s infinite linear; + } +} + +.import-list-preview-table { + height: 500px; + + .pbl-ngrid-row { + height: var(--os-row-height); + } + + .ngrid-preview { + div { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } + } +} + +.import-list-preview-row { + font-size: 14px; +} + +@keyframes rotation { + from { + transform: rotate(0deg); + } + to { + transform: rotate(359deg); + } +} + +.pointer-icon { + cursor: pointer; +} + +.bottom-offset { + margin: 0 0 5px 0; +} diff --git a/client/src/app/ui/modules/import-list/components/via-backend-import-list/backend-import-list.component.spec.ts b/client/src/app/ui/modules/import-list/components/via-backend-import-list/backend-import-list.component.spec.ts new file mode 100644 index 0000000000..81bd1b0ca0 --- /dev/null +++ b/client/src/app/ui/modules/import-list/components/via-backend-import-list/backend-import-list.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BackendImportListComponent } from './backend-import-list.component'; + +xdescribe(`ViaBackendImportListComponent`, () => { + let component: BackendImportListComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [BackendImportListComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(BackendImportListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it(`should create`, () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/ui/modules/import-list/components/via-backend-import-list/backend-import-list.component.ts b/client/src/app/ui/modules/import-list/components/via-backend-import-list/backend-import-list.component.ts new file mode 100644 index 0000000000..e385f3313c --- /dev/null +++ b/client/src/app/ui/modules/import-list/components/via-backend-import-list/backend-import-list.component.ts @@ -0,0 +1,486 @@ +import { + Component, + ContentChild, + ContentChildren, + ElementRef, + EventEmitter, + Input, + OnDestroy, + OnInit, + Output, + QueryList, + TemplateRef, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSelectChange } from '@angular/material/select'; +import { MatTab, MatTabChangeEvent } from '@angular/material/tabs'; +import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; +import { TranslateService } from '@ngx-translate/core'; +import { delay, firstValueFrom, map, Observable, of } from 'rxjs'; +import { Identifiable } from 'src/app/domain/interfaces'; +import { infoDialogSettings } from 'src/app/infrastructure/utils/dialog-settings'; +import { ValueLabelCombination } from 'src/app/infrastructure/utils/import/import-utils'; +import { BackendImportService } from 'src/app/ui/base/import-service'; + +import { ScrollingTableCellDefConfig } from '../../../scrolling-table/directives/scrolling-table-cell-config'; +import { END_POSITION, START_POSITION } from '../../../scrolling-table/directives/scrolling-table-cell-position'; +import { ImportListHeaderDefinition } from '../../definitions'; +import { + BackendImportEntryObject, + BackendImportHeader, + BackendImportIdentifiedRow, + BackendImportPreview, + BackendImportState, + BackendImportSummary +} from '../../definitions/backend-import-preview'; +import { ImportListFirstTabDirective } from '../../directives/import-list-first-tab.directive'; +import { ImportListLastTabDirective } from '../../directives/import-list-last-tab.directive'; +import { ImportListStatusTemplateDirective } from '../../directives/import-list-status-template.directive'; + +export enum BackendImportPhase { + LOADING_PREVIEW, + AWAITING_CONFIRM, + IMPORTING, + FINISHED, + ERROR, + TRY_AGAIN +} + +@Component({ + selector: `os-backend-import-list`, + templateUrl: `./backend-import-list.component.html`, + styleUrls: [`./backend-import-list.component.scss`], + encapsulation: ViewEncapsulation.None +}) +export class BackendImportListComponent implements OnInit, OnDestroy { + public readonly END_POSITION = END_POSITION; + public readonly START_POSITION = START_POSITION; + + @ContentChildren(ImportListFirstTabDirective, { read: MatTab }) + public importListFirstTabs!: QueryList; + + @ContentChildren(ImportListLastTabDirective, { read: MatTab }) + public importListLastTabs!: QueryList; + + @ContentChild(ImportListStatusTemplateDirective, { read: TemplateRef }) + public importListStateTemplate: TemplateRef; + + @ViewChild(`fileInput`) + private fileInput!: ElementRef; + + @Input() + public rowHeight = 50; + + @Input() + public modelName = ``; + + @Input() + public additionalInfo = ``; + + @Input() + public set importer(importer: BackendImportService) { + this._importer = importer; + } + + public get importer(): BackendImportService { + return this._importer; + } + + private _importer!: BackendImportService; + + /** + * Defines all necessary and optional fields, that a .csv-file can contain. + */ + @Input() + public possibleFields: string[] = []; + + @Output() + public selectedTabChanged = new EventEmitter(); + + public readonly Phase = BackendImportPhase; + + /** + * Observable that allows one to monitor the currenty selected file. + */ + public get rawFileObservable(): Observable { + return this._importer?.rawFileObservable || of(null); + } + + /** + * Client-side definition of required/accepted columns. + * Ensures that the client can display information about how the import works. + */ + @Input() + public set defaultColumns(cols: ImportListHeaderDefinition[]) { + this._defaultColumns = cols; + this.setHeaders({ default: cols }); + } + public get defaultColumns(): ImportListHeaderDefinition[] { + return this._defaultColumns; + } + + /** + * The actual headers of the preview, as they were delivered by the backend. + */ + public get previewColumns(): BackendImportHeader[] { + return this._previewColumns; + } + + /** + * The summary of the preview, as it was delivered by the backend. + */ + public get summary(): BackendImportSummary[] { + return this._summary; + } + + /** + * The rows of the preview, which were delivered by the backend. + * Affixed with fake ids for the purpose of displaying them correctly. + */ + public get rows(): BackendImportIdentifiedRow[] { + return this._rows; + } + + /** + * True if, after the first json-upload, the view is waiting for the user to confirm the import. + */ + public get awaitingConfirm(): boolean { + return this._state === BackendImportPhase.AWAITING_CONFIRM; + } + + /** + * True if the import has successfully finished. + */ + public get finishedSuccessfully(): boolean { + return this._state === BackendImportPhase.FINISHED; + } + + /** + * True if, after an attempted import failed, the view is waiting for the user to confirm the import on the new preview. + */ + public get tryAgain(): boolean { + return this._state === BackendImportPhase.TRY_AGAIN; + } + + /** + * True while an import is in progress. + */ + public get isImporting(): boolean { + return this._state === BackendImportPhase.IMPORTING; + } + + /** + * True if the preview can not be imported. + */ + public get hasErrors(): boolean { + return this._state === BackendImportPhase.ERROR; + } + + /** + * Currently selected encoding. Is set and changed by the config's available + * encodings and user mat-select input + */ + public selectedEncoding = `utf-8`; + + public isInFullscreen = false; + + /** + * @returns the encodings available and their labels + */ + public get encodings(): ValueLabelCombination[] { + return this._importer.encodings; + } + + /** + * @returns the available column separators and their labels + */ + public get columnSeparators(): ValueLabelCombination[] { + return this._importer.columnSeparators; + } + + /** + * @eturns the available text separators and their labels + */ + public get textSeparators(): ValueLabelCombination[] { + return this._importer.textSeparators; + } + + /** + * If false there is something wrong with the data. + */ + public get hasRowErrors(): boolean { + return this._importer.previewHasRowErrors; + } + + /** + * Client side information on the required fields of this import. + * Generated from the information in the defaultColumns. + */ + public get requiredFields(): string[] { + return this._requiredFields; + } + + /** + * The Observable from which the views table will be calculated + */ + public get dataSource(): Observable { + return this._dataSource; + } + + private _state: BackendImportPhase = BackendImportPhase.LOADING_PREVIEW; + + private _summary: BackendImportSummary[]; + private _rows: BackendImportIdentifiedRow[]; + private _previewColumns: BackendImportHeader[]; + + private _dataSource: Observable = of([]); + private _requiredFields: string[] = []; + private _defaultColumns: ImportListHeaderDefinition[] = []; + + private _headers: { + [property: string]: { default?: ImportListHeaderDefinition; preview?: BackendImportHeader }; + } = {}; + + public constructor(private dialog: MatDialog, private translate: TranslateService) {} + + /** + * Starts with a clean preview (removing any previously existing import previews) + */ + public ngOnInit(): void { + this._importer.clearAll(); + this._requiredFields = this.createRequiredFields(); + this._importer.currentImportPhaseObservable.subscribe(phase => { + this._state = phase; + }); + this._importer.previewsObservable.subscribe(previews => { + this.fillPreviewData(previews); + }); + this._dataSource = this.importer.previewsObservable.pipe( + map(previews => this.calculateRows(previews)), + delay(50) + ); + } + + /** + * Resets the importer when leaving the view + */ + public ngOnDestroy(): void { + this._importer.clearFile(); + } + + /** + * Triggers a change in the tab group: Clearing the preview selection + */ + public onTabChange({ index }: MatTabChangeEvent): void { + this.removeSelectedFile(); + this._importer.clearAll(); + this.selectedTabChanged.emit(index); + } + + /** + * True if there are custom tabs. + */ + public hasSeveralTabs(): boolean { + return this.importListFirstTabs.length + this.importListLastTabs.length > 0; + } + + /** + * triggers the importer's onSelectFile after a file has been chosen + */ + public onSelectFile(event: any): void { + this._importer.onSelectFile(event); + } + + /** + * Triggers the importer's import + */ + public async doImport(): Promise { + this._importer.doImport(); + } + + /** + * Removes the selected file and also empties the preview. + */ + public removeSelectedFile(clearImporter = true): void { + if (this.fileInput) { + this.fileInput.nativeElement.value = ``; + } + if (clearImporter) { + this._importer.clearFile(); + } + } + + /** + * Gets the relevant backend header information for a property. + */ + public getHeader(propertyName: string): BackendImportHeader { + return this._headers[propertyName]?.preview; + } + + /** + * Gets the style of the column for the given property. + */ + public getColumnConfig(propertyName: string): ScrollingTableCellDefConfig { + const defaultHeader = this._headers[propertyName]?.default; + const colWidth = defaultHeader?.width ?? 50; + const def: ScrollingTableCellDefConfig = { minWidth: Math.max(150, colWidth) }; + if (!defaultHeader?.flexible) { + def.width = colWidth; + } + return def; + } + + /** + * Gets the label of the column for the given property. + */ + public getColumnLabel(propertyName: string): string { + return this._headers[propertyName]?.default?.label ?? propertyName; + } + + /** + * Get the icon for the the item + * @param item a row or an entry with a current state + * @eturn the icon for the item + */ + public getActionIcon(item: BackendImportIdentifiedRow | BackendImportEntryObject): string { + switch (item[`state`] ?? item[`info`]) { + case BackendImportState.Error: // no import possible + return `block`; + case BackendImportState.Warning: + return `warning`; + case BackendImportState.New: + return `add`; + case BackendImportState.Done: // item has been imported + return `done`; + case BackendImportState.Generated: + return `autorenew`; + default: + return `block`; // fallback: Error + } + } + + /** + * Get the correct tooltip for the item + * @param entry a row with a current state + * @eturn the tooltip for the item + */ + public getRowTooltip(row: BackendImportIdentifiedRow): string { + switch (row.state) { + case BackendImportState.Error: // no import possible + return ( + this.getErrorDescription(row) ?? + _(`There is an unspecified error in this line, which prevents the import.`) + ); + case BackendImportState.Warning: + return this.getErrorDescription(row) ?? _(`This row will not be imported, due to an unknown reason.`); + case BackendImportState.New: + return this.translate.instant(this.modelName) + ` ` + this.translate.instant(`will be imported`); + case BackendImportState.Done: // item has been imported + return this.translate.instant(this.modelName) + ` ` + this.translate.instant(`has been imported`); + default: + return undefined; + } + } + + /** + * A function to trigger the csv example download. + */ + public downloadCsvExample(): void { + this._importer.downloadCsvExample(); + } + + /** + * Trigger for the column separator selection. + */ + public selectColSep(event: MatSelectChange): void { + this._importer.columnSeparator = event.value; + this._importer.refreshFile(); + } + + /** + * Trigger for the column separator selection + */ + public selectTextSep(event: MatSelectChange): void { + this._importer.textSeparator = event.value; + this._importer.refreshFile(); + } + + /** + * Trigger for the encoding selection. + */ + public selectEncoding(event: MatSelectChange): void { + this._importer.encoding = event.value; + this._importer.refreshFile(); + } + + /** + * Opens a fullscreen dialog with the given template as content. + */ + public async enterFullscreen(dialogTemplate: TemplateRef): Promise { + this.isInFullscreen = true; + const ref = this.dialog.open(dialogTemplate, { width: `80vw` }); + await firstValueFrom(ref.afterClosed()); + this.isInFullscreen = false; + } + + /** + * Opens an info dialog with the given template as content. + */ + public async openDialog(dialogTemplate: TemplateRef): Promise { + const ref = this.dialog.open(dialogTemplate, infoDialogSettings); + await firstValueFrom(ref.afterClosed()); + } + + /** + * Returns the verbose title for a given summary title. + */ + public getSummaryPointTitle(title: string): string { + return this._importer.getVerboseSummaryPointTitle(title); + } + + private setHeaders(data: { default?: ImportListHeaderDefinition[]; preview?: BackendImportHeader[] }): void { + for (let key of Object.keys(data)) { + for (let header of data[key] ?? []) { + if (!this._headers[header.property]) { + this._headers[header.property] = { [key]: header }; + } else { + this._headers[header.property][key] = header; + } + } + } + } + + private getErrorDescription(entry: BackendImportIdentifiedRow): string { + return entry.messages?.map(error => this.translate.instant(this._importer.verbose(error))).join(`,\n `); + } + + private fillPreviewData(previews: BackendImportPreview[]) { + if (!previews || !previews.length) { + this._previewColumns = undefined; + this._summary = undefined; + this._rows = undefined; + } else { + this._previewColumns = previews[0].headers; + this._summary = previews.flatMap(preview => preview.statistics).filter(point => point.value); + this._rows = this.calculateRows(previews); + this.setHeaders({ preview: this._previewColumns }); + } + } + + private calculateRows(previews: BackendImportPreview[]): BackendImportIdentifiedRow[] { + return previews?.flatMap(preview => preview.rows); + } + + private createRequiredFields(): string[] { + const definitions = this.defaultColumns; + if (Array.isArray(definitions) && definitions.length > 0) { + return definitions + .filter(definition => definition.isRequired as boolean) + .map(definition => definition.property as string); + } else { + return []; + } + } +} diff --git a/client/src/app/ui/modules/import-list/definitions/backend-import-preview.ts b/client/src/app/ui/modules/import-list/definitions/backend-import-preview.ts new file mode 100644 index 0000000000..2741a1ea2f --- /dev/null +++ b/client/src/app/ui/modules/import-list/definitions/backend-import-preview.ts @@ -0,0 +1,68 @@ +import { Identifiable } from 'src/app/domain/interfaces'; + +export enum BackendImportState { + Error = `error`, + Warning = `warning`, + New = `new`, + Done = `done`, + Generated = `generated` + // could be expanded later +} + +export interface BackendImportHeader { + property: string; // field name/column title + type: `boolean` | `number` | `string` | `date` | `object`; // date must be in format yyyy-mm-dd + is_list: boolean; // optional, if not given defaults to `false` +} + +export type BackendImportEntry = null | boolean | number | string | BackendImportEntryObject; + +export type BackendImportEntryObject = { + value: boolean | number | string; + info: BackendImportState; + type: `boolean` | `number` | `string` | `date`; +}; + +export interface BackendImportRow { + state: BackendImportState; + messages: string[]; + data: { + // property name and type must match an entry in the given `headers` + [property: string]: BackendImportEntry | BackendImportEntry[]; // if is_list is set in corresponding header column, we need here also a list + }; +} + +export type BackendImportIdentifiedRow = BackendImportRow & Identifiable; + +export interface BackendImportSummary { + name: string; // text like "Total Number of Items", "Created", "Updated", depending the action + value: number; +} + +export interface BackendImportRawPreview { + id: number; // id of action_worker to import + state: BackendImportState; // May be `error`, `warning` or `done` + headers: BackendImportHeader[]; + rows: BackendImportRow[]; + statistics: BackendImportSummary[]; +} + +export interface BackendImportPreview { + id: number; // id of action_worker to import + state: BackendImportState; // May be `error`, `warning` or `done` + headers: BackendImportHeader[]; + rows: BackendImportIdentifiedRow[]; + statistics: BackendImportSummary[]; +} + +export function isBackendImportRawPreview(obj: any): obj is BackendImportRawPreview { + return ( + obj && + typeof obj === `object` && + typeof obj.id === `number` && + typeof obj.state === `string` && + Array.isArray(obj.headers) && + Array.isArray(obj.rows) && + Array.isArray(obj.statistics) + ); +} diff --git a/client/src/app/ui/modules/import-list/definitions/import-list-header-definition.ts b/client/src/app/ui/modules/import-list/definitions/import-list-header-definition.ts index 0ca2c85ae1..79db9bb905 100644 --- a/client/src/app/ui/modules/import-list/definitions/import-list-header-definition.ts +++ b/client/src/app/ui/modules/import-list/definitions/import-list-header-definition.ts @@ -7,4 +7,5 @@ interface HeaderDefinition { isRequired?: boolean; isTableColumn?: boolean; width?: number; + flexible?: boolean; } diff --git a/client/src/app/ui/modules/import-list/import-list.module.ts b/client/src/app/ui/modules/import-list/import-list.module.ts index c75d21dd76..4cc8d88a7a 100644 --- a/client/src/app/ui/modules/import-list/import-list.module.ts +++ b/client/src/app/ui/modules/import-list/import-list.module.ts @@ -14,6 +14,7 @@ import { ScrollingTableModule } from 'src/app/ui/modules/scrolling-table'; import { OpenSlidesTranslationModule } from '../../../site/modules/translations'; import { ImportListComponent } from './components/import-list/import-list.component'; +import { BackendImportListComponent } from './components/via-backend-import-list/backend-import-list.component'; import { ImportListFirstTabDirective } from './directives/import-list-first-tab.directive'; import { ImportListLastTabDirective } from './directives/import-list-last-tab.directive'; import { ImportListStatusTemplateDirective } from './directives/import-list-status-template.directive'; @@ -22,7 +23,8 @@ const DECLARATIONS = [ ImportListComponent, ImportListFirstTabDirective, ImportListLastTabDirective, - ImportListStatusTemplateDirective + ImportListStatusTemplateDirective, + BackendImportListComponent ]; @NgModule({ diff --git a/client/src/app/ui/modules/scrolling-table/components/scrolling-table/scrolling-table.component.html b/client/src/app/ui/modules/scrolling-table/components/scrolling-table/scrolling-table.component.html index 45e7151e9d..555f5daf88 100644 --- a/client/src/app/ui/modules/scrolling-table/components/scrolling-table/scrolling-table.component.html +++ b/client/src/app/ui/modules/scrolling-table/components/scrolling-table/scrolling-table.component.html @@ -29,7 +29,7 @@