Merge pull request #4114 from MaximilianKrambach/csvimport/all
csv-import for user and agenda(topics)
This commit is contained in:
commit
eec3fba34a
395
client/src/app/core/services/base-import.service.ts
Normal file
395
client/src/app/core/services/base-import.service.ts
Normal file
@ -0,0 +1,395 @@
|
|||||||
|
import { BehaviorSubject, Observable } from 'rxjs';
|
||||||
|
import { Injectable, EventEmitter } from '@angular/core';
|
||||||
|
import { MatSnackBar } from '@angular/material';
|
||||||
|
import { Papa, PapaParseConfig } from 'ngx-papaparse';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
|
||||||
|
import { BaseViewModel } from 'app/site/base/base-view-model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for value- Label combinations.
|
||||||
|
* Map objects didn't work, TODO: Use map objects (needs iterating through all objects of a map)
|
||||||
|
*/
|
||||||
|
export interface ValueLabelCombination {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface matching a newly created entry with their duplicates and an import status
|
||||||
|
*/
|
||||||
|
export interface NewEntry<V> {
|
||||||
|
newEntry: V;
|
||||||
|
status: CsvImportStatus;
|
||||||
|
errors: string[];
|
||||||
|
duplicates: V[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* interface for a preview summary
|
||||||
|
*/
|
||||||
|
export interface ImportCSVPreview {
|
||||||
|
total: number;
|
||||||
|
duplicates: number;
|
||||||
|
errors: number;
|
||||||
|
new: number;
|
||||||
|
done: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The permitted states of a new entry. Only a 'new' entry should be imported
|
||||||
|
* and then be set to 'done'.
|
||||||
|
*/
|
||||||
|
type CsvImportStatus = 'new' | 'error' | 'done';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract service for imports
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export abstract class BaseImportService<V extends BaseViewModel> {
|
||||||
|
/**
|
||||||
|
* List of possible errors and their verbose explanation
|
||||||
|
*/
|
||||||
|
public abstract errorList: Object;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The headers expected in the CSV matching import properties (in order)
|
||||||
|
*/
|
||||||
|
public expectedHeader: (string)[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The minimimal number of header entries needed to successfully create an entry
|
||||||
|
*/
|
||||||
|
public abstract requiredHeaderLength: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last parsed file object (may be reparsed with new encoding, thus kept in memory)
|
||||||
|
*/
|
||||||
|
private _rawFile: File;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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: "'" }
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FileReader object for file import
|
||||||
|
*/
|
||||||
|
private reader = new FileReader();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* the list of parsed models that have been extracted from the opened file
|
||||||
|
*/
|
||||||
|
private _entries: NewEntry<V>[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BehaviorSubject for displaying a preview for the currently selected entries
|
||||||
|
*/
|
||||||
|
public newEntries = new BehaviorSubject<NewEntry<V>[]>([]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits an error string to display if a file import cannot be done
|
||||||
|
*/
|
||||||
|
public errorEvent = new EventEmitter<string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* storing the summary preview for the import, to avoid recalculating it
|
||||||
|
* at each display change.
|
||||||
|
*/
|
||||||
|
protected _preview: ImportCSVPreview;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a summary on actions that will be taken/not taken.
|
||||||
|
*/
|
||||||
|
public get summary(): ImportCSVPreview {
|
||||||
|
if (!this._preview) {
|
||||||
|
this.updatePreview();
|
||||||
|
}
|
||||||
|
return this._preview;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current entries. For internal use in extending classes, as it
|
||||||
|
* might not be filled with data at all times (see {@link newEntries} for a BehaviorSubject)
|
||||||
|
*/
|
||||||
|
protected get entries(): NewEntry<V>[] {
|
||||||
|
return this._entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor. Creates a fileReader to subscribe to it for incoming parsed
|
||||||
|
* strings
|
||||||
|
*
|
||||||
|
* @param translate Translation service
|
||||||
|
* @param papa External csv parser (ngx-papaparser)
|
||||||
|
* @param matSnackBar snackBar to display import errors
|
||||||
|
*/
|
||||||
|
public constructor(protected translate: TranslateService, private papa: Papa, protected matSnackbar: MatSnackBar) {
|
||||||
|
this.reader.onload = (event: any) => {
|
||||||
|
// TODO type: event is a progressEvent,
|
||||||
|
// but has a property target.result, which typescript doesn't recognize
|
||||||
|
this.parseInput(event.target.result);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all stored secondary data
|
||||||
|
* TODO: Merge with clearPreview()
|
||||||
|
*/
|
||||||
|
public abstract clearData(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the data input. Expects a string as returned by via a
|
||||||
|
* File.readAsText() operation
|
||||||
|
*
|
||||||
|
* @param file
|
||||||
|
*/
|
||||||
|
public parseInput(file: string): void {
|
||||||
|
this.clearData();
|
||||||
|
this.clearPreview();
|
||||||
|
const papaConfig: PapaParseConfig = {
|
||||||
|
header: false,
|
||||||
|
skipEmptyLines: true,
|
||||||
|
quoteChar: this.textSeparator
|
||||||
|
};
|
||||||
|
if (this.columnSeparator) {
|
||||||
|
papaConfig.delimiter = this.columnSeparator;
|
||||||
|
}
|
||||||
|
const entryLines = this.papa.parse(file, papaConfig).data;
|
||||||
|
const valid = this.checkHeader(entryLines.shift());
|
||||||
|
if (!valid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entryLines.forEach(line => {
|
||||||
|
const item = this.mapData(line);
|
||||||
|
if (item) {
|
||||||
|
this._entries.push(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.newEntries.next(this._entries);
|
||||||
|
this.updatePreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* parses pre-prepared entries (e.g. from a textarea) instead of a csv structure
|
||||||
|
*
|
||||||
|
* @param entries: an array of prepared newEntry objects
|
||||||
|
*/
|
||||||
|
public setParsedEntries(entries: NewEntry<V>[]): void {
|
||||||
|
this.clearData();
|
||||||
|
this.clearPreview();
|
||||||
|
if (!entries) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._entries = entries;
|
||||||
|
this.newEntries.next(this._entries);
|
||||||
|
this.updatePreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsing an string representing an entry, extracting secondary data,
|
||||||
|
* returning a new entry object
|
||||||
|
* @param line a line extracted by the CSV (not including the header)
|
||||||
|
*/
|
||||||
|
public abstract mapData(line: string): NewEntry<V>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger for executing the import.
|
||||||
|
*/
|
||||||
|
public abstract async doImport(): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* counts the amount of duplicates that have no decision on the action to
|
||||||
|
* be taken
|
||||||
|
*/
|
||||||
|
public updatePreview(): void {
|
||||||
|
const summary = {
|
||||||
|
total: 0,
|
||||||
|
new: 0,
|
||||||
|
duplicates: 0,
|
||||||
|
errors: 0,
|
||||||
|
done: 0
|
||||||
|
};
|
||||||
|
this._entries.forEach(entry => {
|
||||||
|
summary.total += 1;
|
||||||
|
if (entry.status === 'done') {
|
||||||
|
summary.done += 1;
|
||||||
|
return;
|
||||||
|
} else if (entry.status === 'error' && !entry.duplicates.length) {
|
||||||
|
// errors that are not due to duplicates
|
||||||
|
summary.errors += 1;
|
||||||
|
return;
|
||||||
|
} else if (entry.duplicates.length) {
|
||||||
|
summary.duplicates += 1;
|
||||||
|
return;
|
||||||
|
} else if (entry.status === 'new') {
|
||||||
|
summary.new += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this._preview = summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* a subscribable representation of the new items to be imported
|
||||||
|
*
|
||||||
|
* @returns an observable BehaviorSubject
|
||||||
|
*/
|
||||||
|
public getNewEntries(): Observable<NewEntry<V>[]> {
|
||||||
|
return this.newEntries.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
// TODO type
|
||||||
|
if (event.target.files && event.target.files.length === 1) {
|
||||||
|
if (event.target.files[0].type === 'text/csv') {
|
||||||
|
this._rawFile = event.target.files[0];
|
||||||
|
this.readFile(event.target.files[0]);
|
||||||
|
} else {
|
||||||
|
this.matSnackbar.open(this.translate.instant('Wrong file type detected. Import failed.'), '', {
|
||||||
|
duration: 3000
|
||||||
|
});
|
||||||
|
this.clearPreview();
|
||||||
|
this._rawFile = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(this._rawFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (re)-reads a given file with the current parameter
|
||||||
|
*/
|
||||||
|
private readFile(file: File): void {
|
||||||
|
this.reader.readAsText(file, this.encoding);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks the first line of the csv (the header) for consistency (length)
|
||||||
|
*
|
||||||
|
* @param row expected to be an array parsed from the first line of a csv file
|
||||||
|
* @returns true if the line has at least the minimum amount of columns
|
||||||
|
*/
|
||||||
|
private checkHeader(row: string[]): boolean {
|
||||||
|
const snackbarDuration = 3000;
|
||||||
|
if (row.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;
|
||||||
|
} else if (row.length < this.expectedHeader.length) {
|
||||||
|
this.matSnackbar.open(
|
||||||
|
this.translate.instant('The file seems to have some ommitted columns. They will be considered empty.'),
|
||||||
|
'',
|
||||||
|
{ duration: snackbarDuration }
|
||||||
|
);
|
||||||
|
} else if (row.length > this.expectedHeader.length) {
|
||||||
|
this.matSnackbar.open(
|
||||||
|
this.translate.instant('The file seems to have additional columns. They will be ignored.'),
|
||||||
|
'',
|
||||||
|
{ duration: snackbarDuration }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the data and preview (triggered upon selecting an invalid file)
|
||||||
|
*/
|
||||||
|
public clearPreview(): void {
|
||||||
|
this._entries = [];
|
||||||
|
this.newEntries.next([]);
|
||||||
|
this._preview = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* set a list of short names for error, indicating which column failed
|
||||||
|
*/
|
||||||
|
public setError(entry: NewEntry<V>, error: string): void {
|
||||||
|
if (this.errorList.hasOwnProperty(error)) {
|
||||||
|
if (!entry.errors) {
|
||||||
|
entry.errors = [error];
|
||||||
|
} else if (!entry.errors.includes(error)) {
|
||||||
|
entry.errors.push(error);
|
||||||
|
entry.status = 'error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an extended error description.
|
||||||
|
*
|
||||||
|
* @param error
|
||||||
|
* @returns the extended error desription for that error
|
||||||
|
*/
|
||||||
|
public verbose(error: string): string {
|
||||||
|
return this.errorList[error];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queries if a given error is present in the given entry
|
||||||
|
*
|
||||||
|
* @param entry the entry to check for the error.
|
||||||
|
* @param error The error to check for
|
||||||
|
* @returns true if the error is present
|
||||||
|
*/
|
||||||
|
public hasError(entry: NewEntry<V>, error: string): boolean {
|
||||||
|
return entry.errors.includes(error);
|
||||||
|
}
|
||||||
|
}
|
@ -33,12 +33,12 @@ export class User extends ProjectableBaseModel implements Searchable {
|
|||||||
const addition: string[] = [];
|
const addition: string[] = [];
|
||||||
|
|
||||||
// addition: add number and structure level
|
// addition: add number and structure level
|
||||||
const structure_level = this.structure_level.trim();
|
const structure_level = this.structure_level ? this.structure_level.trim() : '';
|
||||||
if (structure_level) {
|
if (structure_level) {
|
||||||
addition.push(structure_level);
|
addition.push(structure_level);
|
||||||
}
|
}
|
||||||
|
|
||||||
const number = this.number.trim();
|
const number = this.number ? this.number.trim() : null;
|
||||||
if (number) {
|
if (number) {
|
||||||
// TODO Translate
|
// TODO Translate
|
||||||
addition.push('No.' + ' ' + number);
|
addition.push('No.' + ' ' + number);
|
||||||
@ -55,11 +55,16 @@ export class User extends ProjectableBaseModel implements Searchable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO read config values for "users_sort_by"
|
// TODO read config values for "users_sort_by"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getter for the short name (Title, given name, surname)
|
||||||
|
*
|
||||||
|
* @returns a non-empty string
|
||||||
|
*/
|
||||||
public get short_name(): string {
|
public get short_name(): string {
|
||||||
const title = this.title.trim();
|
const title = this.title ? this.title.trim() : '';
|
||||||
const firstName = this.first_name.trim();
|
const firstName = this.first_name ? this.first_name.trim() : '';
|
||||||
const lastName = this.last_name.trim();
|
const lastName = this.last_name ? this.last_name.trim() : '';
|
||||||
let shortName = '';
|
|
||||||
|
|
||||||
// TODO need DS adjustment first first
|
// TODO need DS adjustment first first
|
||||||
// if (this.DS.getConfig('users_sort_by').value === 'last_name') {
|
// if (this.DS.getConfig('users_sort_by').value === 'last_name') {
|
||||||
@ -70,9 +75,9 @@ export class User extends ProjectableBaseModel implements Searchable {
|
|||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
|
|
||||||
shortName += `${firstName} ${lastName}`;
|
let shortName = `${firstName} ${lastName}`;
|
||||||
|
|
||||||
if (shortName.trim() === '') {
|
if (!shortName) {
|
||||||
shortName = this.username;
|
shortName = this.username;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,7 +85,7 @@ export class User extends ProjectableBaseModel implements Searchable {
|
|||||||
shortName = `${title} ${shortName}`;
|
shortName = `${title} ${shortName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return shortName.trim();
|
return shortName;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getTitle(): string {
|
public getTitle(): string {
|
||||||
|
@ -25,6 +25,7 @@ import {
|
|||||||
MatButtonToggleModule,
|
MatButtonToggleModule,
|
||||||
MatBadgeModule,
|
MatBadgeModule,
|
||||||
MatStepperModule,
|
MatStepperModule,
|
||||||
|
MatTabsModule,
|
||||||
MatBottomSheetModule
|
MatBottomSheetModule
|
||||||
} from '@angular/material';
|
} from '@angular/material';
|
||||||
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
||||||
@ -118,6 +119,7 @@ import { LogoComponent } from './components/logo/logo.component';
|
|||||||
MatRadioModule,
|
MatRadioModule,
|
||||||
MatButtonToggleModule,
|
MatButtonToggleModule,
|
||||||
MatStepperModule,
|
MatStepperModule,
|
||||||
|
MatTabsModule,
|
||||||
DragDropModule,
|
DragDropModule,
|
||||||
TranslateModule.forChild(),
|
TranslateModule.forChild(),
|
||||||
RouterModule,
|
RouterModule,
|
||||||
@ -151,6 +153,7 @@ import { LogoComponent } from './components/logo/logo.component';
|
|||||||
MatSnackBarModule,
|
MatSnackBarModule,
|
||||||
MatChipsModule,
|
MatChipsModule,
|
||||||
MatTooltipModule,
|
MatTooltipModule,
|
||||||
|
MatTabsModule,
|
||||||
MatBadgeModule,
|
MatBadgeModule,
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
MatRadioModule,
|
MatRadioModule,
|
||||||
|
17
client/src/app/site/agenda/agenda-import.service.spec.ts
Normal file
17
client/src/app/site/agenda/agenda-import.service.spec.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { AgendaImportService } from './agenda-import.service';
|
||||||
|
import { E2EImportsModule } from 'e2e-imports.module';
|
||||||
|
|
||||||
|
describe('AgendaImportService', () => {
|
||||||
|
beforeEach(() =>
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [E2EImportsModule]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
const service: AgendaImportService = TestBed.get(AgendaImportService);
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
216
client/src/app/site/agenda/agenda-import.service.ts
Normal file
216
client/src/app/site/agenda/agenda-import.service.ts
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { MatSnackBar } from '@angular/material';
|
||||||
|
import { Papa } from 'ngx-papaparse';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
|
||||||
|
import { BaseImportService, NewEntry } from 'app/core/services/base-import.service';
|
||||||
|
import { CreateTopic } from './models/create-topic';
|
||||||
|
import { DurationService } from 'app/core/services/duration.service';
|
||||||
|
import { itemVisibilityChoices } from 'app/shared/models/agenda/item';
|
||||||
|
import { TopicRepositoryService } from './services/topic-repository.service';
|
||||||
|
import { ViewCreateTopic } from './models/view-create-topic';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class AgendaImportService extends BaseImportService<ViewCreateTopic> {
|
||||||
|
/**
|
||||||
|
* Helper for mapping the expected header in a typesafe way. Values will be passed to
|
||||||
|
* {@link expectedHeader}
|
||||||
|
*/
|
||||||
|
public headerMap: (keyof ViewCreateTopic)[] = ['title', 'text', 'duration', 'comment', 'type'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The minimimal number of header entries needed to successfully create an entry
|
||||||
|
*/
|
||||||
|
public requiredHeaderLength = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of possible errors and their verbose explanation
|
||||||
|
*/
|
||||||
|
public errorList = {
|
||||||
|
NoTitle: 'A Topic needs a title',
|
||||||
|
Duplicates: 'A topic tiwh this title already exists',
|
||||||
|
ParsingErrors: 'Some csv values could not be read correctly.'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor. Calls the abstract class and sets the expected header
|
||||||
|
*
|
||||||
|
* @param durationService: a service for converting time strings and numbers
|
||||||
|
* @param repo: The Agenda repository service
|
||||||
|
* @param translate A translation service for translating strings
|
||||||
|
* @param papa Csv parser
|
||||||
|
* @param matSnackBar MatSnackBar for displaying errors
|
||||||
|
*/
|
||||||
|
public constructor(
|
||||||
|
private durationService: DurationService,
|
||||||
|
private repo: TopicRepositoryService,
|
||||||
|
translate: TranslateService,
|
||||||
|
papa: Papa,
|
||||||
|
matSnackBar: MatSnackBar
|
||||||
|
) {
|
||||||
|
super(translate, papa, matSnackBar);
|
||||||
|
this.expectedHeader = this.headerMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all secondary import data. As agenda items have no secondary imports,
|
||||||
|
* this is an empty function
|
||||||
|
*/
|
||||||
|
public clearData(): void {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a string representing an entry
|
||||||
|
*
|
||||||
|
* @param line a line extracted by the CSV (without the header)
|
||||||
|
* @returns a new entry for a Topic
|
||||||
|
*/
|
||||||
|
public mapData(line: string): NewEntry<ViewCreateTopic> {
|
||||||
|
const newEntry = new ViewCreateTopic(new CreateTopic());
|
||||||
|
const headerLength = Math.min(this.expectedHeader.length, line.length);
|
||||||
|
let hasErrors = false;
|
||||||
|
for (let idx = 0; idx < headerLength; idx++) {
|
||||||
|
switch (this.expectedHeader[idx]) {
|
||||||
|
case 'duration':
|
||||||
|
try {
|
||||||
|
const duration = this.parseDuration(line[idx]);
|
||||||
|
if (duration > 0) {
|
||||||
|
newEntry.duration = duration;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof TypeError) {
|
||||||
|
hasErrors = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'type':
|
||||||
|
try {
|
||||||
|
newEntry.type = this.parseType(line[idx]);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof TypeError) {
|
||||||
|
hasErrors = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
newEntry[this.expectedHeader[idx]] = line[idx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const updateModels = this.repo.getTopicDuplicates(newEntry) as ViewCreateTopic[];
|
||||||
|
|
||||||
|
// set type to 'public' if none is given in import
|
||||||
|
if (!newEntry.type) {
|
||||||
|
newEntry.type = 1;
|
||||||
|
}
|
||||||
|
const mappedEntry: NewEntry<ViewCreateTopic> = {
|
||||||
|
newEntry: newEntry,
|
||||||
|
duplicates: [],
|
||||||
|
status: 'new',
|
||||||
|
errors: []
|
||||||
|
};
|
||||||
|
if (updateModels.length) {
|
||||||
|
mappedEntry.duplicates = updateModels;
|
||||||
|
this.setError(mappedEntry, 'Duplicates');
|
||||||
|
}
|
||||||
|
if (hasErrors) {
|
||||||
|
this.setError(mappedEntry, 'ParsingErrors');
|
||||||
|
}
|
||||||
|
if (!mappedEntry.newEntry.isValid) {
|
||||||
|
this.setError(mappedEntry, 'NoTitle');
|
||||||
|
}
|
||||||
|
return mappedEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executing the import. Parses all entries without errors and submits them
|
||||||
|
* to the server. The entries will receive the status 'done' on success.
|
||||||
|
*/
|
||||||
|
public async doImport(): Promise<void> {
|
||||||
|
for (const entry of this.entries) {
|
||||||
|
if (entry.status !== 'new') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await this.repo.create(entry.newEntry.topic);
|
||||||
|
entry.status = 'done';
|
||||||
|
}
|
||||||
|
this.updatePreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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): number {
|
||||||
|
if (!input) {
|
||||||
|
return 1; // default, public item
|
||||||
|
} else if (typeof input === 'string') {
|
||||||
|
const visibility = itemVisibilityChoices.find(choice => choice.csvName === input);
|
||||||
|
if (visibility) {
|
||||||
|
return visibility.key;
|
||||||
|
}
|
||||||
|
} else if (input === 1) {
|
||||||
|
// Compatibility with the old client's isInternal column
|
||||||
|
const visibility = itemVisibilityChoices.find(choice => choice.name === 'Internal item');
|
||||||
|
if (visibility) {
|
||||||
|
return visibility.key;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new TypeError('type could not be matched');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* parses the data given by the textArea. Expects an agenda title per line
|
||||||
|
*
|
||||||
|
* @param data a string as produced by textArea input
|
||||||
|
*/
|
||||||
|
public parseTextArea(data: string): void {
|
||||||
|
const newEntries: NewEntry<ViewCreateTopic>[] = [];
|
||||||
|
this.clearData();
|
||||||
|
this.clearPreview();
|
||||||
|
const lines = data.split('\n');
|
||||||
|
lines.forEach(line => {
|
||||||
|
if (!line.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newTopic = new ViewCreateTopic(
|
||||||
|
new CreateTopic({
|
||||||
|
title: line,
|
||||||
|
agenda_type: 1 // set type to 'public item' by default
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const newEntry: NewEntry<ViewCreateTopic> = {
|
||||||
|
newEntry: newTopic,
|
||||||
|
duplicates: [],
|
||||||
|
status: 'new',
|
||||||
|
errors: []
|
||||||
|
};
|
||||||
|
const duplicates = this.repo.getTopicDuplicates(newTopic);
|
||||||
|
if (duplicates.length) {
|
||||||
|
// TODO this is a dishonest casting. duplicates should not be required to be View
|
||||||
|
newEntry.duplicates = duplicates as ViewCreateTopic[];
|
||||||
|
this.setError(newEntry, 'Duplicates');
|
||||||
|
}
|
||||||
|
newEntries.push(newEntry);
|
||||||
|
});
|
||||||
|
this.setParsedEntries(newEntries);
|
||||||
|
}
|
||||||
|
}
|
@ -1,11 +1,14 @@
|
|||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
import { Routes, RouterModule } from '@angular/router';
|
import { Routes, RouterModule } from '@angular/router';
|
||||||
|
|
||||||
|
import { AgendaImportListComponent } from './components/agenda-import-list/agenda-import-list.component';
|
||||||
import { AgendaListComponent } from './components/agenda-list/agenda-list.component';
|
import { AgendaListComponent } from './components/agenda-list/agenda-list.component';
|
||||||
import { TopicDetailComponent } from './components/topic-detail/topic-detail.component';
|
import { TopicDetailComponent } from './components/topic-detail/topic-detail.component';
|
||||||
import { SpeakerListComponent } from './components/speaker-list/speaker-list.component';
|
import { SpeakerListComponent } from './components/speaker-list/speaker-list.component';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{ path: '', component: AgendaListComponent },
|
{ path: '', component: AgendaListComponent },
|
||||||
|
{ path: 'import', component: AgendaImportListComponent },
|
||||||
{ path: 'topics/new', component: TopicDetailComponent },
|
{ path: 'topics/new', component: TopicDetailComponent },
|
||||||
{ path: 'topics/:id', component: TopicDetailComponent },
|
{ path: 'topics/:id', component: TopicDetailComponent },
|
||||||
{ path: ':id/speakers', component: SpeakerListComponent }
|
{ path: ':id/speakers', component: SpeakerListComponent }
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
import { AgendaImportListComponent } from './components/agenda-import-list/agenda-import-list.component';
|
||||||
|
import { AgendaListComponent } from './components/agenda-list/agenda-list.component';
|
||||||
|
import { ItemInfoDialogComponent } from './components/item-info-dialog/item-info-dialog.component';
|
||||||
import { AgendaRoutingModule } from './agenda-routing.module';
|
import { AgendaRoutingModule } from './agenda-routing.module';
|
||||||
import { SharedModule } from '../../shared/shared.module';
|
import { SharedModule } from '../../shared/shared.module';
|
||||||
import { AgendaListComponent } from './components/agenda-list/agenda-list.component';
|
|
||||||
import { TopicDetailComponent } from './components/topic-detail/topic-detail.component';
|
import { TopicDetailComponent } from './components/topic-detail/topic-detail.component';
|
||||||
import { ItemInfoDialogComponent } from './components/item-info-dialog/item-info-dialog.component';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AppModule for the agenda and it's children.
|
* AppModule for the agenda and it's children.
|
||||||
@ -13,6 +14,6 @@ import { ItemInfoDialogComponent } from './components/item-info-dialog/item-info
|
|||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [CommonModule, AgendaRoutingModule, SharedModule],
|
imports: [CommonModule, AgendaRoutingModule, SharedModule],
|
||||||
entryComponents: [ItemInfoDialogComponent],
|
entryComponents: [ItemInfoDialogComponent],
|
||||||
declarations: [AgendaListComponent, TopicDetailComponent, ItemInfoDialogComponent]
|
declarations: [AgendaListComponent, TopicDetailComponent, ItemInfoDialogComponent, AgendaImportListComponent]
|
||||||
})
|
})
|
||||||
export class AgendaModule {}
|
export class AgendaModule {}
|
||||||
|
@ -0,0 +1,226 @@
|
|||||||
|
|
||||||
|
<os-head-bar [nav]="false">
|
||||||
|
<!-- Title -->
|
||||||
|
<div class="title-slot"><h2 translate>Import topics</h2></div>
|
||||||
|
|
||||||
|
<div class="menu-slot">
|
||||||
|
<button *ngIf="hasFile && newCount" mat-button (click)="doImport()">
|
||||||
|
<span class="upper" translate> Import</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</os-head-bar>
|
||||||
|
|
||||||
|
<mat-card class="os-form-card import-table">
|
||||||
|
<mat-tab-group (selectedTabChange)="onTabChange()">
|
||||||
|
<mat-tab label="{{ 'CSV import' | translate }}">
|
||||||
|
<span translate>Required comma or semicolon separated values with these column header names in the first row:</span>
|
||||||
|
<br />
|
||||||
|
<div class="code red-warning-text">
|
||||||
|
<span translate>Title</span>,
|
||||||
|
<span translate>Text</span>,
|
||||||
|
<span translate>Duration</span>,
|
||||||
|
<span translate>Comment</span>,
|
||||||
|
<span translate>Internal item</span>
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
<li translate>Title is required. All other fields are optional and may be empty.
|
||||||
|
</li>
|
||||||
|
<li translate>Additional columns after the required ones may be present and won't affect the import.</li>
|
||||||
|
</ul>
|
||||||
|
<button mat-button color="accent" (click)="downloadCsvExample()" translate>Download CSV example file</button>
|
||||||
|
<div class="wrapper">
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label translate>Encoding of the file</mat-label>
|
||||||
|
<mat-select
|
||||||
|
class="selection"
|
||||||
|
placeholder="translate.instant('Select encoding')"
|
||||||
|
(selectionChange)="selectEncoding($event)"
|
||||||
|
[value]="encodings[0].value"
|
||||||
|
>
|
||||||
|
<mat-option *ngFor="let option of encodings" [value]="option.value">
|
||||||
|
{{ option.label | translate }}
|
||||||
|
</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label translate> Column Separator</mat-label>
|
||||||
|
<mat-select class="selection" (selectionChange)="selectColSep($event)" value="">
|
||||||
|
<mat-option *ngFor="let option of columnSeparators" [value]="option.value">
|
||||||
|
{{ option.label | translate }}
|
||||||
|
</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label translate>Text separator</mat-label>
|
||||||
|
<mat-select class="selection" (selectionChange)="selectTextSep($event)" value=""">
|
||||||
|
<mat-option *ngFor="let option of textSeparators" [value]="option.value">
|
||||||
|
{{ option.label | translate }}
|
||||||
|
</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
id="agenda-import-file-input"
|
||||||
|
type="file"
|
||||||
|
class="hidden-input"
|
||||||
|
accept="text"
|
||||||
|
#fileInput
|
||||||
|
(change)="onSelectFile($event)"
|
||||||
|
/>
|
||||||
|
<button mat-button onclick="document.getElementById('agenda-import-file-input').click()">
|
||||||
|
<span translate> Select file</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-tab>
|
||||||
|
<!-- textAreaImport-->
|
||||||
|
<mat-tab label="{{ 'Text import' | translate }}">
|
||||||
|
<div [formGroup]="textAreaForm">
|
||||||
|
<div>
|
||||||
|
<span translate>
|
||||||
|
Paste/write your topics in this textbox.</span>
|
||||||
|
<span translate>
|
||||||
|
Keep each item in a single line.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<mat-form-field>
|
||||||
|
<textarea
|
||||||
|
matInput
|
||||||
|
formControlName="inputtext"
|
||||||
|
placeholder="{{ 'Insert topics here' | translate }}"
|
||||||
|
cdkTextareaAutosize
|
||||||
|
cdkAutosizeMinRows="3"
|
||||||
|
cdkAutosizeMaxRows="10"
|
||||||
|
></textarea>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button mat-button color="accent" (click)="parseTextArea()"><span translate>Preview</span></button>
|
||||||
|
</div>
|
||||||
|
</mat-tab>
|
||||||
|
</mat-tab-group>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<!-- preview table -->
|
||||||
|
<mat-card *ngIf="hasFile" class="os-form-card import-table">
|
||||||
|
<h3 translate> Preview</h3>
|
||||||
|
<div class="summary">
|
||||||
|
<!-- new entries -->
|
||||||
|
<div *ngIf="newCount">
|
||||||
|
|
||||||
|
<mat-icon inline>playlist_add</mat-icon>
|
||||||
|
<span> {{ newCount }} </span> <span translate>Topics(s) will be imported.</span>
|
||||||
|
</div>
|
||||||
|
<!-- errors/duplicates -->
|
||||||
|
<div *ngIf="nonImportableCount" class="red-warning-text">
|
||||||
|
|
||||||
|
<mat-icon inline>warning</mat-icon>
|
||||||
|
<span> {{ nonImportableCount }} </span> <span translate>entries will be ommitted.</span>
|
||||||
|
</div>
|
||||||
|
<!-- have been imported -->
|
||||||
|
<div *ngIf="doneCount" class="green-text">
|
||||||
|
|
||||||
|
<mat-icon inline>done</mat-icon>
|
||||||
|
<span> {{ doneCount }} </span> <span translate>Topics have been imported.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="newCount">
|
||||||
|
<span translate>Click on 'import' (right top corner) to import the new topics.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<mat-select *ngIf="nonImportableCount" class="filter-imports" [(value)]="shown" (selectionChange)="setFilter()">
|
||||||
|
<mat-option value="all" translate> Show all </mat-option>
|
||||||
|
<mat-option value="error" translate> Show errors only </mat-option>
|
||||||
|
<mat-option value="noerror" translate> Show correct entries </mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<table mat-table class="on-transition-fade" [dataSource]="dataSource" matSort>
|
||||||
|
<!-- Status column -->
|
||||||
|
<ng-container matColumnDef="status" class="first-column" sticky>
|
||||||
|
<mat-header-cell *matHeaderCellDef></mat-header-cell>
|
||||||
|
<mat-cell *matCellDef="let entry" class="first-column">
|
||||||
|
<div *ngIf="entry.status === 'error'">
|
||||||
|
<mat-icon
|
||||||
|
class="red-warning-text"
|
||||||
|
matTooltip="{{ entry.errors.length }} {{ 'errors' | translate }}"
|
||||||
|
>
|
||||||
|
{{ getActionIcon(entry) }}
|
||||||
|
</mat-icon>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="entry.status === 'new'">
|
||||||
|
<mat-icon matTooltip="{{ 'Topic will be imported' | translate }}">
|
||||||
|
{{ getActionIcon(entry) }}
|
||||||
|
</mat-icon>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="entry.status === 'done'">
|
||||||
|
<mat-icon matTooltip="{{ 'Topic has been imported' | translate }}">
|
||||||
|
{{ getActionIcon(entry) }}
|
||||||
|
</mat-icon>
|
||||||
|
</div>
|
||||||
|
</mat-cell>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- title column -->
|
||||||
|
<ng-container matColumnDef="title">
|
||||||
|
<mat-header-cell *matHeaderCellDef translate>Title</mat-header-cell>
|
||||||
|
<mat-cell *matCellDef="let entry">
|
||||||
|
<mat-icon
|
||||||
|
color="warn"
|
||||||
|
inline
|
||||||
|
*ngIf="hasError(entry, 'Duplicates')"
|
||||||
|
matTooltip="{{ getVerboseError('Duplicates') | translate }}"
|
||||||
|
>
|
||||||
|
warning
|
||||||
|
</mat-icon>
|
||||||
|
<mat-icon
|
||||||
|
color="warn"
|
||||||
|
inline
|
||||||
|
*ngIf="hasError(entry, 'NoTitle')"
|
||||||
|
matTooltip="{{ getVerboseError('NoTitle') | translate }}"
|
||||||
|
>
|
||||||
|
warning
|
||||||
|
</mat-icon>
|
||||||
|
{{ entry.newEntry.title }}
|
||||||
|
</mat-cell>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="text">
|
||||||
|
<mat-header-cell *matHeaderCellDef translate>Item text</mat-header-cell>
|
||||||
|
<mat-cell *matCellDef="let entry" matTooltip="{{ getLongPreview(entry.newEntry.text) }}">
|
||||||
|
{{ getShortPreview(entry.newEntry.text) }}
|
||||||
|
</mat-cell>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- duration column -->
|
||||||
|
<ng-container matColumnDef="duration">
|
||||||
|
<mat-header-cell *matHeaderCellDef translate>Duration</mat-header-cell>
|
||||||
|
<mat-cell *matCellDef="let entry">
|
||||||
|
{{ getDuration(entry.newEntry.duration) }}
|
||||||
|
</mat-cell>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- comment column-->
|
||||||
|
<ng-container matColumnDef="comment">
|
||||||
|
<mat-header-cell *matHeaderCellDef translate>Comment</mat-header-cell>
|
||||||
|
<mat-cell *matCellDef="let entry">
|
||||||
|
{{ entry.newEntry.comment }}
|
||||||
|
</mat-cell>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- type column -->
|
||||||
|
<ng-container matColumnDef="type">
|
||||||
|
<mat-header-cell *matHeaderCellDef translate>Type</mat-header-cell>
|
||||||
|
<mat-cell *matCellDef="let entry">
|
||||||
|
{{ getTypeString(entry.newEntry.type) | translate }}
|
||||||
|
</mat-cell>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<mat-header-row *matHeaderRowDef="getColumnDefinition()"></mat-header-row>
|
||||||
|
<mat-row [ngClass]="getStateClass(row)" *matRowDef="let row; columns: getColumnDefinition()"> </mat-row>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</mat-card>
|
@ -0,0 +1,26 @@
|
|||||||
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { AgendaImportListComponent } from './agenda-import-list.component';
|
||||||
|
import { E2EImportsModule } from 'e2e-imports.module';
|
||||||
|
|
||||||
|
describe('AgendaImportListComponent', () => {
|
||||||
|
let component: AgendaImportListComponent;
|
||||||
|
let fixture: ComponentFixture<AgendaImportListComponent>;
|
||||||
|
|
||||||
|
beforeEach(async(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
declarations: [AgendaImportListComponent],
|
||||||
|
imports: [E2EImportsModule]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(AgendaImportListComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,156 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { FormGroup, FormBuilder } from '@angular/forms';
|
||||||
|
import { MatSnackBar } from '@angular/material';
|
||||||
|
import { Title } from '@angular/platform-browser';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
|
||||||
|
import { AgendaImportService } from '../../agenda-import.service';
|
||||||
|
import { BaseImportListComponent } from 'app/site/base/base-import-list';
|
||||||
|
import { DurationService } from 'app/core/services/duration.service';
|
||||||
|
import { FileExportService } from 'app/core/services/file-export.service';
|
||||||
|
import { itemVisibilityChoices } from 'app/shared/models/agenda/item';
|
||||||
|
import { ViewCreateTopic } from '../../models/view-create-topic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component for the agenda import list view.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'os-agenda-import-list',
|
||||||
|
templateUrl: './agenda-import-list.component.html'
|
||||||
|
})
|
||||||
|
export class AgendaImportListComponent extends BaseImportListComponent<ViewCreateTopic> {
|
||||||
|
/**
|
||||||
|
* A form for text input
|
||||||
|
*/
|
||||||
|
public textAreaForm: FormGroup;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor for list view bases
|
||||||
|
*
|
||||||
|
* @param titleService the title serivce
|
||||||
|
* @param matSnackBar snackbar for displaying errors
|
||||||
|
* @param translate the translate service
|
||||||
|
* @param importer: The agenda csv import service
|
||||||
|
* @param formBuilder: FormBuilder for the textarea
|
||||||
|
* @param exporter: ExportService for example download
|
||||||
|
* @param durationService: Service converting numbers to time strings
|
||||||
|
*/
|
||||||
|
public constructor(
|
||||||
|
titleService: Title,
|
||||||
|
matSnackBar: MatSnackBar,
|
||||||
|
translate: TranslateService,
|
||||||
|
importer: AgendaImportService,
|
||||||
|
formBuilder: FormBuilder,
|
||||||
|
private exporter: FileExportService,
|
||||||
|
private durationService: DurationService
|
||||||
|
) {
|
||||||
|
super(importer, titleService, translate, matSnackBar);
|
||||||
|
this.textAreaForm = formBuilder.group({ inputtext: [''] });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the first characters of a string, for preview purposes
|
||||||
|
*
|
||||||
|
* @param input any string
|
||||||
|
* @returns a string with at most 50 characters
|
||||||
|
*/
|
||||||
|
public getShortPreview(input: string): string {
|
||||||
|
if (!input) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (input.length > 50) {
|
||||||
|
return this.stripHtmlTags(input.substring(0, 47)) + '...';
|
||||||
|
}
|
||||||
|
return this.stripHtmlTags(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the first and last 150 characters of a string; used within
|
||||||
|
* tooltips for the preview
|
||||||
|
*
|
||||||
|
* @param input any string
|
||||||
|
* @returns a string with the first and last 150 characters of the input
|
||||||
|
* string
|
||||||
|
*/
|
||||||
|
public getLongPreview(input: string): string {
|
||||||
|
if (!input) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (input.length < 300) {
|
||||||
|
return this.stripHtmlTags(input);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
this.stripHtmlTags(input.substring(0, 147)) +
|
||||||
|
' [...] ' +
|
||||||
|
this.stripHtmlTags(input.substring(input.length - 150, input.length))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to remove html tags from a string.
|
||||||
|
* CAUTION: It is just a basic "don't show distracting html tags in a
|
||||||
|
* preview", not an actual tested sanitizer!
|
||||||
|
*
|
||||||
|
* @param inputString
|
||||||
|
* @returns a string without hatml tags
|
||||||
|
*/
|
||||||
|
private stripHtmlTags(inputString: string): string {
|
||||||
|
const regexp = new RegExp(/<[^ ][^<>]*(>|$)/g);
|
||||||
|
return inputString.replace(regexp, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers an example csv download
|
||||||
|
*/
|
||||||
|
public downloadCsvExample(): void {
|
||||||
|
const headerRow = ['Title', 'Text', 'Duration', 'Comment', 'Internal item']
|
||||||
|
.map(item => this.translate.instant(item))
|
||||||
|
.join(',');
|
||||||
|
const rows = [
|
||||||
|
headerRow,
|
||||||
|
'Demo 1,Demo text 1,1:00,test comment,',
|
||||||
|
'Break,,0:10,,internal',
|
||||||
|
'Demo 2,Demo text 2,1:30,,hidden'
|
||||||
|
];
|
||||||
|
this.exporter.saveFile(rows.join('\n'), this.translate.instant('Topic example') + '.csv');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the string to a item_type
|
||||||
|
*
|
||||||
|
* @param type
|
||||||
|
* @returns A string, which may be empty if the type is not found in the visibilityChoices
|
||||||
|
*/
|
||||||
|
public getTypeString(type: number): string {
|
||||||
|
const visibility = itemVisibilityChoices.find(choice => choice.key === type);
|
||||||
|
return visibility ? visibility.name : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends the data in the text field input area to the importer
|
||||||
|
*/
|
||||||
|
public parseTextArea(): void {
|
||||||
|
(this.importer as AgendaImportService).parseTextArea(this.textAreaForm.get('inputtext').value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers a change in the tab group: Clearing the preview selection
|
||||||
|
*/
|
||||||
|
public onTabChange(): void {
|
||||||
|
this.importer.clearPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a readable string for a duration
|
||||||
|
*
|
||||||
|
* @param duration
|
||||||
|
* @returns a duration string, an empty string if the duration is not set or negative
|
||||||
|
*/
|
||||||
|
public getDuration(duration: number): string {
|
||||||
|
if (duration >= 0) {
|
||||||
|
return this.durationService.durationToString(duration);
|
||||||
|
} else {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -107,6 +107,10 @@
|
|||||||
<mat-icon>archive</mat-icon>
|
<mat-icon>archive</mat-icon>
|
||||||
<span translate>Export as CSV</span>
|
<span translate>Export as CSV</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button mat-menu-item *osPerms="'agenda.can_manage'" routerLink="import">
|
||||||
|
<mat-icon>save_alt</mat-icon>
|
||||||
|
<span translate>Import</span><span> ...</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div *ngIf="isMultiSelect">
|
<div *ngIf="isMultiSelect">
|
||||||
|
@ -11,10 +11,10 @@ import { PromptService } from '../../../../core/services/prompt.service';
|
|||||||
import { ViewItem } from '../../models/view-item';
|
import { ViewItem } from '../../models/view-item';
|
||||||
|
|
||||||
import { AgendaCsvExportService } from '../../services/agenda-csv-export.service';
|
import { AgendaCsvExportService } from '../../services/agenda-csv-export.service';
|
||||||
|
import { ConfigService } from 'app/core/services/config.service';
|
||||||
|
import { DurationService } from 'app/core/services/duration.service';
|
||||||
import { ItemInfoDialogComponent } from '../item-info-dialog/item-info-dialog.component';
|
import { ItemInfoDialogComponent } from '../item-info-dialog/item-info-dialog.component';
|
||||||
import { ViewportService } from 'app/core/services/viewport.service';
|
import { ViewportService } from 'app/core/services/viewport.service';
|
||||||
import { DurationService } from 'app/site/core/services/duration.service';
|
|
||||||
import { ConfigService } from 'app/core/services/config.service';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List view for the agenda.
|
* List view for the agenda.
|
||||||
|
@ -4,7 +4,7 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material';
|
|||||||
|
|
||||||
import { ViewItem } from '../../models/view-item';
|
import { ViewItem } from '../../models/view-item';
|
||||||
import { itemVisibilityChoices } from 'app/shared/models/agenda/item';
|
import { itemVisibilityChoices } from 'app/shared/models/agenda/item';
|
||||||
import { DurationService } from 'app/site/core/services/duration.service';
|
import { DurationService } from 'app/core/services/duration.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dialog component to change agenda item details
|
* Dialog component to change agenda item details
|
||||||
|
@ -15,6 +15,7 @@ import { BehaviorSubject } from 'rxjs';
|
|||||||
import { DataStoreService } from 'app/core/services/data-store.service';
|
import { DataStoreService } from 'app/core/services/data-store.service';
|
||||||
import { Mediafile } from 'app/shared/models/mediafiles/mediafile';
|
import { Mediafile } from 'app/shared/models/mediafiles/mediafile';
|
||||||
import { Item, itemVisibilityChoices } from 'app/shared/models/agenda/item';
|
import { Item, itemVisibilityChoices } from 'app/shared/models/agenda/item';
|
||||||
|
import { CreateTopic } from '../../models/create-topic';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detail page for topics.
|
* Detail page for topics.
|
||||||
@ -125,7 +126,7 @@ export class TopicDetailComponent extends BaseViewComponent {
|
|||||||
if (!this.topicForm.value.agenda_parent_id) {
|
if (!this.topicForm.value.agenda_parent_id) {
|
||||||
delete this.topicForm.value.agenda_parent_id;
|
delete this.topicForm.value.agenda_parent_id;
|
||||||
}
|
}
|
||||||
await this.repo.create(this.topicForm.value);
|
await this.repo.create(new CreateTopic(this.topicForm.value));
|
||||||
this.router.navigate([`/agenda/`]);
|
this.router.navigate([`/agenda/`]);
|
||||||
} else {
|
} else {
|
||||||
this.setEditMode(false);
|
this.setEditMode(false);
|
||||||
|
17
client/src/app/site/agenda/models/create-topic.ts
Normal file
17
client/src/app/site/agenda/models/create-topic.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { Topic } from 'app/shared/models/topics/topic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Representation of Topic during creation.
|
||||||
|
*/
|
||||||
|
export class CreateTopic extends Topic {
|
||||||
|
public attachments_id: number[];
|
||||||
|
public agenda_type: number;
|
||||||
|
public agenda_parent_id: number;
|
||||||
|
public agenda_comment: string;
|
||||||
|
public agenda_duration: number;
|
||||||
|
public agenda_weight: number;
|
||||||
|
|
||||||
|
public constructor(input?: any) {
|
||||||
|
super(input);
|
||||||
|
}
|
||||||
|
}
|
110
client/src/app/site/agenda/models/view-create-topic.ts
Normal file
110
client/src/app/site/agenda/models/view-create-topic.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { CreateTopic } from './create-topic';
|
||||||
|
import { ViewTopic } from './view-topic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* View model for Topic('Agenda item') creation.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export class ViewCreateTopic extends ViewTopic {
|
||||||
|
public get topic(): CreateTopic {
|
||||||
|
return this._topic as CreateTopic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the field representing the new title
|
||||||
|
*
|
||||||
|
* @returns title string as set during import (may be different from getTitle)
|
||||||
|
*/
|
||||||
|
public get title(): string {
|
||||||
|
return this.topic.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setter for the title. Sets the title of the underlying CreateTopic
|
||||||
|
*
|
||||||
|
* @param title
|
||||||
|
*/
|
||||||
|
public set title(title: string) {
|
||||||
|
this.topic.title = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns the duration in minutes
|
||||||
|
*/
|
||||||
|
public get duration(): number {
|
||||||
|
return this.topic.agenda_duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setter for the duration. Expects values as in {@link DurationService}
|
||||||
|
*/
|
||||||
|
public set duration(duration: number) {
|
||||||
|
this.topic.agenda_duration = duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns the comment string as set during the import
|
||||||
|
*/
|
||||||
|
public get comment(): string {
|
||||||
|
return this.topic.agenda_comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the comment string of the underlying topic
|
||||||
|
* @param comment A string to set as comment
|
||||||
|
*/
|
||||||
|
public set comment(comment: string) {
|
||||||
|
this.topic.agenda_comment = comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns a number representing the item type
|
||||||
|
*/
|
||||||
|
public get type(): number {
|
||||||
|
return this.topic.agenda_type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* sets the item type for the topic's agenda entry. No validation is done here.
|
||||||
|
*
|
||||||
|
* @param A number representing the item's type. See {@link itemVisibilityChoices}
|
||||||
|
* for the interpretation of type numbers.
|
||||||
|
*/
|
||||||
|
public set type(type: number) {
|
||||||
|
this.topic.agenda_type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the text string of the underlying topic
|
||||||
|
*
|
||||||
|
* @param text A string.
|
||||||
|
*/
|
||||||
|
public set text(text: string) {
|
||||||
|
this.topic.text = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns the comment string of the underlying topic
|
||||||
|
*/
|
||||||
|
public get text(): string {
|
||||||
|
return this.topic.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the CreateTopic is valid. Currently only requires an existing title
|
||||||
|
*
|
||||||
|
* @returns true if it is a valid Topic
|
||||||
|
*/
|
||||||
|
public get isValid(): boolean {
|
||||||
|
return this.title ? true : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor. Empty
|
||||||
|
*
|
||||||
|
* @param topic A CreateTopic
|
||||||
|
*/
|
||||||
|
public constructor(topic: CreateTopic) {
|
||||||
|
super(topic);
|
||||||
|
}
|
||||||
|
}
|
@ -9,7 +9,7 @@ import { BaseModel } from 'app/shared/models/base/base-model';
|
|||||||
* @ignore
|
* @ignore
|
||||||
*/
|
*/
|
||||||
export class ViewTopic extends BaseViewModel {
|
export class ViewTopic extends BaseViewModel {
|
||||||
private _topic: Topic;
|
protected _topic: Topic;
|
||||||
private _attachments: Mediafile[];
|
private _attachments: Mediafile[];
|
||||||
private _agenda_item: Item;
|
private _agenda_item: Item;
|
||||||
|
|
||||||
|
@ -9,6 +9,7 @@ import { DataSendService } from 'app/core/services/data-send.service';
|
|||||||
import { ViewTopic } from '../models/view-topic';
|
import { ViewTopic } from '../models/view-topic';
|
||||||
import { Identifiable } from 'app/shared/models/base/identifiable';
|
import { Identifiable } from 'app/shared/models/base/identifiable';
|
||||||
import { CollectionStringModelMapperService } from 'app/core/services/collectionStringModelMapper.service';
|
import { CollectionStringModelMapperService } from 'app/core/services/collectionStringModelMapper.service';
|
||||||
|
import { CreateTopic } from '../models/create-topic';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Repository for topics
|
* Repository for topics
|
||||||
@ -61,10 +62,8 @@ export class TopicRepositoryService extends BaseRepository<ViewTopic, Topic> {
|
|||||||
* @param topicData Partial topic data to be created
|
* @param topicData Partial topic data to be created
|
||||||
* @returns an Identifiable (usually id) as promise
|
* @returns an Identifiable (usually id) as promise
|
||||||
*/
|
*/
|
||||||
public async create(topicData: Partial<Topic>): Promise<Identifiable> {
|
public async create(topic: CreateTopic): Promise<Identifiable> {
|
||||||
const newTopic = new Topic();
|
return await this.dataSend.createModel(topic);
|
||||||
newTopic.patchValues(topicData);
|
|
||||||
return await this.dataSend.createModel(newTopic);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -89,4 +88,16 @@ export class TopicRepositoryService extends BaseRepository<ViewTopic, Topic> {
|
|||||||
public async delete(viewTopic: ViewTopic): Promise<void> {
|
public async delete(viewTopic: ViewTopic): Promise<void> {
|
||||||
return await this.dataSend.deleteModel(viewTopic.topic);
|
return await this.dataSend.deleteModel(viewTopic.topic);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an array of all duplicates for a topic
|
||||||
|
*
|
||||||
|
* @param topic
|
||||||
|
*/
|
||||||
|
public getTopicDuplicates(topic: ViewTopic): ViewTopic[] {
|
||||||
|
const duplicates = this.DS.filter(Topic, item => topic.title === item.title);
|
||||||
|
const viewTopics: ViewTopic[] = [];
|
||||||
|
duplicates.forEach(item => viewTopics.push(this.createViewModel(item)));
|
||||||
|
return viewTopics;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
276
client/src/app/site/base/base-import-list.ts
Normal file
276
client/src/app/site/base/base-import-list.ts
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
import { MatTableDataSource, MatTable, MatSnackBar, MatSelectChange } from '@angular/material';
|
||||||
|
import { ViewChild, OnInit } from '@angular/core';
|
||||||
|
|
||||||
|
import { BaseViewComponent } from './base-view';
|
||||||
|
import { BaseViewModel } from './base-view-model';
|
||||||
|
import { NewEntry, ValueLabelCombination, BaseImportService } from 'app/core/services/base-import.service';
|
||||||
|
import { Title } from '@angular/platform-browser';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
|
||||||
|
export abstract class BaseImportListComponent<V extends BaseViewModel> extends BaseViewComponent implements OnInit {
|
||||||
|
/**
|
||||||
|
* The data source for a table. Requires to be initialised with a BaseViewModel
|
||||||
|
*/
|
||||||
|
public dataSource: MatTableDataSource<NewEntry<V>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch that turns true if a file has been selected in the input
|
||||||
|
*/
|
||||||
|
public hasFile = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Currently selected encoding. Is set and changed by the config's available
|
||||||
|
* encodings and user mat-select input
|
||||||
|
*/
|
||||||
|
public selectedEncoding = 'utf-8';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* indicator on which elements to display
|
||||||
|
*/
|
||||||
|
public shown: 'all' | 'error' | 'noerror' = 'all';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The table itself
|
||||||
|
*/
|
||||||
|
@ViewChild(MatTable)
|
||||||
|
protected table: MatTable<NewEntry<V>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns the amount of total item successfully parsed
|
||||||
|
*/
|
||||||
|
public get totalCount(): number {
|
||||||
|
return this.importer && this.hasFile ? this.importer.summary.total : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns the amount of import items that will be imported
|
||||||
|
*/
|
||||||
|
public get newCount(): number {
|
||||||
|
return this.importer && this.hasFile ? this.importer.summary.new : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns the number of import items that cannot be imported
|
||||||
|
*/
|
||||||
|
public get nonImportableCount(): number {
|
||||||
|
if (this.importer && this.hasFile) {
|
||||||
|
return this.importer.summary.errors + this.importer.summary.duplicates;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns the number of import items that have been successfully imported
|
||||||
|
*/
|
||||||
|
public get doneCount(): number {
|
||||||
|
return this.importer && this.hasFile ? this.importer.summary.done : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor. Initializes the table and subscribes to import errors
|
||||||
|
*
|
||||||
|
* @param importer The import service, depending on the implementation
|
||||||
|
* @param titleService A title service
|
||||||
|
* @param translate TranslationService for translating strings
|
||||||
|
* @param matSnackBar MatSnackBar for displaying errors
|
||||||
|
*/
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
protected importer: BaseImportService<V>,
|
||||||
|
titleService: Title,
|
||||||
|
translate: TranslateService,
|
||||||
|
matSnackBar: MatSnackBar
|
||||||
|
) {
|
||||||
|
super(titleService, translate, matSnackBar);
|
||||||
|
this.initTable();
|
||||||
|
this.importer.errorEvent.subscribe(this.raiseError);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts with a clean preview (removing any previously existing import previews)
|
||||||
|
*/
|
||||||
|
public ngOnInit(): void {
|
||||||
|
this.importer.clearPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the table
|
||||||
|
*/
|
||||||
|
public initTable(): void {
|
||||||
|
this.dataSource = new MatTableDataSource();
|
||||||
|
this.setFilter();
|
||||||
|
this.importer.getNewEntries().subscribe(newEntries => {
|
||||||
|
this.dataSource.data = newEntries;
|
||||||
|
this.hasFile = newEntries.length > 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the table column definition. Fetches all headers from
|
||||||
|
* {@link MotionImportService} and an additional status column
|
||||||
|
*
|
||||||
|
* @returns An array of the columns forming the import header, and an additional 'status' bar on the front
|
||||||
|
*/
|
||||||
|
public getColumnDefinition(): string[] {
|
||||||
|
return ['status'].concat(this.importer.expectedHeader);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<void> {
|
||||||
|
await this.importer.doImport();
|
||||||
|
this.setFilter();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates and manually triggers the filter function.
|
||||||
|
* See {@link hidden} for options
|
||||||
|
* (changed from default mat-table filter)
|
||||||
|
*/
|
||||||
|
public setFilter(): void {
|
||||||
|
this.dataSource.filter = '';
|
||||||
|
if (this.shown === 'all') {
|
||||||
|
this.dataSource.filterPredicate = (data, filter) => {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
} else if (this.shown === 'noerror') {
|
||||||
|
this.dataSource.filterPredicate = (data, filter) => {
|
||||||
|
if (data.status === 'done') {
|
||||||
|
return true;
|
||||||
|
} else if (data.status !== 'error') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else if (this.shown === 'error') {
|
||||||
|
this.dataSource.filterPredicate = (data, filter) => {
|
||||||
|
if (data.errors.length || data.duplicates.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
this.dataSource.filter = 'X'; // TODO: This is just a bogus non-null string to trigger the filter
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the appropiate css class for a row according to the import state
|
||||||
|
*
|
||||||
|
* @param row a newEntry object with a current status
|
||||||
|
* @returns a css class name
|
||||||
|
*/
|
||||||
|
public getStateClass(row: NewEntry<V>): string {
|
||||||
|
switch (row.status) {
|
||||||
|
case 'done':
|
||||||
|
return 'import-done import-decided';
|
||||||
|
case 'error':
|
||||||
|
return 'import-error';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the icon for the action of the item
|
||||||
|
* @param entry a newEntry object with a current status
|
||||||
|
* @eturn the icon for the action of the item
|
||||||
|
*/
|
||||||
|
public getActionIcon(entry: NewEntry<V>): string {
|
||||||
|
switch (entry.status) {
|
||||||
|
case 'error': // no import possible
|
||||||
|
return 'block';
|
||||||
|
case 'new':
|
||||||
|
return '';
|
||||||
|
case 'done': // item has been imported
|
||||||
|
return 'done';
|
||||||
|
default:
|
||||||
|
// fallback: Error
|
||||||
|
return 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A function to trigger the csv example download.
|
||||||
|
*/
|
||||||
|
public abstract downloadCsvExample(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger for the column separator selection.
|
||||||
|
*
|
||||||
|
* @param event
|
||||||
|
*/
|
||||||
|
public selectColSep(event: MatSelectChange): void {
|
||||||
|
this.importer.columnSeparator = event.value;
|
||||||
|
this.importer.refreshFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger for the column separator selection
|
||||||
|
*
|
||||||
|
* @param event
|
||||||
|
*/
|
||||||
|
public selectTextSep(event: MatSelectChange): void {
|
||||||
|
this.importer.textSeparator = event.value;
|
||||||
|
this.importer.refreshFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger for the encoding selection
|
||||||
|
*
|
||||||
|
* @param event
|
||||||
|
*/
|
||||||
|
public selectEncoding(event: MatSelectChange): void {
|
||||||
|
this.importer.encoding = event.value;
|
||||||
|
this.importer.refreshFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a descriptive string for an import error
|
||||||
|
*
|
||||||
|
* @param error The short string for the error as listed in the {@lilnk errorList}
|
||||||
|
* @returns a predefined descriptive error string from the importer
|
||||||
|
*/
|
||||||
|
public getVerboseError(error: string): string {
|
||||||
|
return this.importer.verbose(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an error is present in a new entry
|
||||||
|
*
|
||||||
|
* @param row the NewEntry
|
||||||
|
* @param error An error as defined as key of {@link errorList}
|
||||||
|
* @returns true if the error is present in the entry described in the row
|
||||||
|
*/
|
||||||
|
public hasError(row: NewEntry<V>, error: string): boolean {
|
||||||
|
return this.importer.hasError(row, error);
|
||||||
|
}
|
||||||
|
}
|
@ -9,11 +9,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</os-head-bar>
|
</os-head-bar>
|
||||||
|
|
||||||
<mat-card>
|
<mat-card class="os-form-card import-table">
|
||||||
<span translate>Requires comma or semicolon separated values with these column header names in the first row</span>:
|
<span translate>Requires comma or semicolon separated values with these column header names in the first row</span>:
|
||||||
<br />
|
<br />
|
||||||
<div class="code red-warning-text">
|
<div class="code red-warning-text">
|
||||||
<!-- TODO: class : indent, warning color -->
|
|
||||||
<span translate>Identifier</span>, <span translate>Title</span>, <span translate>Text</span>,
|
<span translate>Identifier</span>, <span translate>Title</span>, <span translate>Text</span>,
|
||||||
<span translate>Reason</span>, <span translate>Submitter</span>, <span translate>Category</span>,
|
<span translate>Reason</span>, <span translate>Submitter</span>, <span translate>Category</span>,
|
||||||
<span translate>Origin</span>, <span translate>Motion block</span>
|
<span translate>Origin</span>, <span translate>Motion block</span>
|
||||||
@ -23,7 +22,6 @@
|
|||||||
Identifier, reason, submitter, category, origin and motion block are optional and may be empty.
|
Identifier, reason, submitter, category, origin and motion block are optional and may be empty.
|
||||||
</li>
|
</li>
|
||||||
<li translate>Additional columns after the required ones may be present and won't affect the import.</li>
|
<li translate>Additional columns after the required ones may be present and won't affect the import.</li>
|
||||||
<li translate>Only double quotes are accepted as text delimiter (no single quotes).</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<button mat-button color="accent" (click)="downloadCsvExample()" translate>Download CSV example file</button>
|
<button mat-button color="accent" (click)="downloadCsvExample()" translate>Download CSV example file</button>
|
||||||
<div class="wrapper">
|
<div class="wrapper">
|
||||||
@ -71,12 +69,12 @@
|
|||||||
<span translate> Select file</span>
|
<span translate> Select file</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<span *ngIf="hasFile">{{ totalCount }} <span translate>entries found.</span></span>
|
|
||||||
</div>
|
</div>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
|
|
||||||
<!-- preview table -->
|
<!-- preview table -->
|
||||||
<mat-card *ngIf="hasFile">
|
<mat-card *ngIf="hasFile" class="os-form-card import-table">
|
||||||
|
<h3 translate>Preview</h3>
|
||||||
<div class="summary">
|
<div class="summary">
|
||||||
<!-- new entries -->
|
<!-- new entries -->
|
||||||
<div *ngIf="newCount">
|
<div *ngIf="newCount">
|
||||||
@ -97,35 +95,38 @@
|
|||||||
<span> {{ doneCount }} </span> <span translate>Motions have been imported.</span>
|
<span> {{ doneCount }} </span> <span translate>Motions have been imported.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div *ngIf="newCount">
|
||||||
|
<span translate>Click on 'import' (right top corner) to import the new motions.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<mat-select [(value)]="shown" (selectionChange)="setFilter()">
|
<mat-select *ngIf="nonImportableCount" class="filter-imports" [(value)]="shown" (selectionChange)="setFilter()">
|
||||||
<!-- TODO: reduce item width to sane value -->
|
|
||||||
<mat-option value="all" translate> Show all </mat-option>
|
<mat-option value="all" translate> Show all </mat-option>
|
||||||
<mat-option *ngIf="nonImportableCount" value="error" translate> Show errors only </mat-option>
|
<mat-option value="error" translate> Show errors only </mat-option>
|
||||||
<mat-option *ngIf="nonImportableCount" value="noerror" translate> Show correct entries </mat-option>
|
<mat-option value="noerror" translate> Show correct entries </mat-option>
|
||||||
</mat-select>
|
</mat-select>
|
||||||
<!-- TODO: Button to hide imported ones -->
|
|
||||||
</div>
|
</div>
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table mat-table class="on-transition-fade" [dataSource]="dataSource" matSort>
|
<table mat-table class="on-transition-fade" [dataSource]="dataSource" matSort>
|
||||||
|
|
||||||
<!-- Status column -->
|
<!-- Status column -->
|
||||||
<ng-container matColumnDef="status" sticky>
|
<ng-container matColumnDef="status" sticky>
|
||||||
<mat-header-cell *matHeaderCellDef></mat-header-cell>
|
<mat-header-cell *matHeaderCellDef class="first-column"></mat-header-cell>
|
||||||
<mat-cell *matCellDef="let entry">
|
<mat-cell *matCellDef="let entry" class="first-column">
|
||||||
<div *ngIf="entry.newMotion.status === 'error'">
|
<div *ngIf="entry.status === 'error'">
|
||||||
<mat-icon
|
<mat-icon
|
||||||
class="red-warning-text"
|
class="red-warning-text"
|
||||||
matTooltip="{{ entry.newMotion.errors.length }} + {{ 'errors' | translate }}"
|
matTooltip="{{ entry.errors.length }} {{ 'errors' | translate }}"
|
||||||
>
|
>
|
||||||
{{ getActionIcon(entry) }}
|
{{ getActionIcon(entry) }}
|
||||||
</mat-icon>
|
</mat-icon>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="entry.newMotion.status === 'new'">
|
<div *ngIf="entry.status === 'new'">
|
||||||
<mat-icon matTooltip="{{ 'Motion will be imported' | translate }}">
|
<mat-icon matTooltip="{{ 'Motion will be imported' | translate }}">
|
||||||
{{ getActionIcon(entry) }}
|
{{ getActionIcon(entry) }}
|
||||||
</mat-icon>
|
</mat-icon>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="entry.newMotion.status === 'done'">
|
<div *ngIf="entry.status === 'done'">
|
||||||
<mat-icon matTooltip="{{ 'Motion has been imported' | translate }}">
|
<mat-icon matTooltip="{{ 'Motion has been imported' | translate }}">
|
||||||
{{ getActionIcon(entry) }}
|
{{ getActionIcon(entry) }}
|
||||||
</mat-icon>
|
</mat-icon>
|
||||||
@ -140,12 +141,12 @@
|
|||||||
<mat-icon
|
<mat-icon
|
||||||
color="warn"
|
color="warn"
|
||||||
inline
|
inline
|
||||||
*ngIf="entry.newMotion.hasError('Duplicates')"
|
*ngIf="hasError(entry, 'Duplicates')"
|
||||||
matTooltip="{{ getVerboseError('Duplicates') | translate }}"
|
matTooltip="{{ getVerboseError('Duplicates') | translate }}"
|
||||||
>
|
>
|
||||||
warning
|
warning
|
||||||
</mat-icon>
|
</mat-icon>
|
||||||
{{ entry.newMotion.identifier }}
|
{{ entry.newEntry.identifier }}
|
||||||
</mat-cell>
|
</mat-cell>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
@ -155,35 +156,35 @@
|
|||||||
<mat-cell *matCellDef="let entry">
|
<mat-cell *matCellDef="let entry">
|
||||||
<mat-icon
|
<mat-icon
|
||||||
color="warn"
|
color="warn"
|
||||||
*ngIf="entry.newMotion.hasError('Title')"
|
*ngIf="hasError(entry, 'Title')"
|
||||||
matTooltip="{{ getVerboseError('Title') | translate }}"
|
matTooltip="{{ getVerboseError('Title') | translate }}"
|
||||||
>
|
>
|
||||||
warning
|
warning
|
||||||
</mat-icon>
|
</mat-icon>
|
||||||
{{ entry.newMotion.title }}
|
{{ entry.newEntry.title }}
|
||||||
</mat-cell>
|
</mat-cell>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<!-- tect column TODO: Bigger-->
|
<!-- text column -->
|
||||||
<ng-container matColumnDef="text">
|
<ng-container matColumnDef="text">
|
||||||
<mat-header-cell *matHeaderCellDef translate>Motion text</mat-header-cell>
|
<mat-header-cell *matHeaderCellDef translate>Motion text</mat-header-cell>
|
||||||
<mat-cell *matCellDef="let entry" matTooltip="{{ getLongPreview(entry.newMotion.text) }}">
|
<mat-cell *matCellDef="let entry" matTooltip="{{ getLongPreview(entry.newEntry.text) }}">
|
||||||
<mat-icon
|
<mat-icon
|
||||||
color="warn"
|
color="warn"
|
||||||
*ngIf="entry.newMotion.hasError('Text')"
|
*ngIf="hasError(entry, 'Text')"
|
||||||
matTooltip="{{ getVerboseError('Text') | translate }}"
|
matTooltip="{{ getVerboseError('Text') | translate }}"
|
||||||
>
|
>
|
||||||
warning
|
warning
|
||||||
</mat-icon>
|
</mat-icon>
|
||||||
{{ getShortPreview(entry.newMotion.text) }}
|
{{ getShortPreview(entry.newEntry.text) }}
|
||||||
</mat-cell>
|
</mat-cell>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<!-- reason column -->
|
<!-- reason column -->
|
||||||
<ng-container matColumnDef="reason">
|
<ng-container matColumnDef="reason">
|
||||||
<mat-header-cell *matHeaderCellDef translate>Reason</mat-header-cell>
|
<mat-header-cell *matHeaderCellDef translate>Reason</mat-header-cell>
|
||||||
<mat-cell *matCellDef="let entry" matTooltip="{{ getLongPreview(entry.newMotion.reason) }}">
|
<mat-cell *matCellDef="let entry" matTooltip="{{ getLongPreview(entry.newEntry.reason) }}">
|
||||||
{{ getShortPreview(entry.newMotion.reason) }}
|
{{ getShortPreview(entry.newEntry.reason) }}
|
||||||
</mat-cell>
|
</mat-cell>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
@ -191,15 +192,15 @@
|
|||||||
<ng-container matColumnDef="submitters">
|
<ng-container matColumnDef="submitters">
|
||||||
<mat-header-cell *matHeaderCellDef translate>Submitters</mat-header-cell>
|
<mat-header-cell *matHeaderCellDef translate>Submitters</mat-header-cell>
|
||||||
<mat-cell *matCellDef="let entry">
|
<mat-cell *matCellDef="let entry">
|
||||||
<div *ngIf="entry.newMotion.csvSubmitters.length">
|
<div *ngIf="entry.newEntry.csvSubmitters.length">
|
||||||
<mat-icon
|
<mat-icon
|
||||||
color="warn"
|
color="warn"
|
||||||
*ngIf="entry.newMotion.hasError('Submitters')"
|
*ngIf="hasError(entry, 'Submitters')"
|
||||||
matTooltip="{{ getVerboseError('Submitters') | translate }}"
|
matTooltip="{{ getVerboseError('Submitters') | translate }}"
|
||||||
>
|
>
|
||||||
warning
|
warning
|
||||||
</mat-icon>
|
</mat-icon>
|
||||||
<span *ngFor="let submitter of entry.newMotion.csvSubmitters">
|
<span *ngFor="let submitter of entry.newEntry.csvSubmitters">
|
||||||
{{ submitter.name }}
|
{{ submitter.name }}
|
||||||
<mat-icon class="newBadge" color="accent" inline *ngIf="!submitter.id">add</mat-icon>
|
<mat-icon class="newBadge" color="accent" inline *ngIf="!submitter.id">add</mat-icon>
|
||||||
|
|
||||||
@ -212,16 +213,16 @@
|
|||||||
<ng-container matColumnDef="category">
|
<ng-container matColumnDef="category">
|
||||||
<mat-header-cell *matHeaderCellDef translate>Category</mat-header-cell>
|
<mat-header-cell *matHeaderCellDef translate>Category</mat-header-cell>
|
||||||
<mat-cell *matCellDef="let entry">
|
<mat-cell *matCellDef="let entry">
|
||||||
<div *ngIf="entry.newMotion.csvCategory">
|
<div *ngIf="entry.newEntry.csvCategory">
|
||||||
<mat-icon
|
<mat-icon
|
||||||
color="warn"
|
color="warn"
|
||||||
*ngIf="entry.newMotion.hasError('Category')"
|
*ngIf="hasError(entry, 'Category')"
|
||||||
matTooltip="{{ getVerboseError('Category') | translate }}"
|
matTooltip="{{ getVerboseError('Category') | translate }}"
|
||||||
>
|
>
|
||||||
warning
|
warning
|
||||||
</mat-icon>
|
</mat-icon>
|
||||||
{{ entry.newMotion.csvCategory.name }}
|
{{ entry.newEntry.csvCategory.name }}
|
||||||
<mat-icon class="newBadge" color="accent" inline *ngIf="!entry.newMotion.csvCategory.id"
|
<mat-icon class="newBadge" color="accent" inline *ngIf="!entry.newEntry.csvCategory.id"
|
||||||
>add</mat-icon
|
>add</mat-icon
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@ -231,23 +232,23 @@
|
|||||||
<!-- origin column -->
|
<!-- origin column -->
|
||||||
<ng-container matColumnDef="origin">
|
<ng-container matColumnDef="origin">
|
||||||
<mat-header-cell *matHeaderCellDef translate>Origin</mat-header-cell>
|
<mat-header-cell *matHeaderCellDef translate>Origin</mat-header-cell>
|
||||||
<mat-cell *matCellDef="let entry">{{ entry.newMotion.origin }}</mat-cell>
|
<mat-cell *matCellDef="let entry">{{ entry.newEntry.origin }}</mat-cell>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<!-- motion block column -->
|
<!-- motion block column -->
|
||||||
<ng-container matColumnDef="motion block">
|
<ng-container matColumnDef="motion_block">
|
||||||
<mat-header-cell *matHeaderCellDef translate>Motion block</mat-header-cell>
|
<mat-header-cell *matHeaderCellDef translate>Motion block</mat-header-cell>
|
||||||
<mat-cell *matCellDef="let entry">
|
<mat-cell *matCellDef="let entry">
|
||||||
<div *ngIf="entry.newMotion.csvMotionblock">
|
<div *ngIf="entry.newEntry.csvMotionblock">
|
||||||
<mat-icon
|
<mat-icon
|
||||||
color="warn"
|
color="warn"
|
||||||
*ngIf="entry.newMotion.hasError('MotionBlock')"
|
*ngIf="hasError(entry, 'MotionBlock')"
|
||||||
matTooltip="{{ getVerboseError('MotionBlock') | translate }}"
|
matTooltip="{{ getVerboseError('MotionBlock') | translate }}"
|
||||||
>
|
>
|
||||||
warning
|
warning
|
||||||
</mat-icon>
|
</mat-icon>
|
||||||
{{ entry.newMotion.csvMotionblock.name }}
|
{{ entry.newEntry.csvMotionblock.name }}
|
||||||
<mat-icon class="newBadge" color="accent" inline *ngIf="!entry.newMotion.csvMotionblock.id">
|
<mat-icon class="newBadge" color="accent" inline *ngIf="!entry.newEntry.csvMotionblock.id">
|
||||||
add
|
add
|
||||||
</mat-icon>
|
</mat-icon>
|
||||||
|
|
||||||
@ -256,7 +257,6 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<mat-header-row *matHeaderRowDef="getColumnDefinition()"></mat-header-row>
|
<mat-header-row *matHeaderRowDef="getColumnDefinition()"></mat-header-row>
|
||||||
<!-- TODO: class for invalid/undecided -->
|
|
||||||
<mat-row [ngClass]="getStateClass(row)" *matRowDef="let row; columns: getColumnDefinition()"> </mat-row>
|
<mat-row [ngClass]="getStateClass(row)" *matRowDef="let row; columns: getColumnDefinition()"> </mat-row>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,53 +0,0 @@
|
|||||||
.table-container {
|
|
||||||
width: 100%;
|
|
||||||
overflow-x: scroll;
|
|
||||||
margin-top: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
overflow: scroll;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-header-cell {
|
|
||||||
min-width: 100px;
|
|
||||||
padding-right: 8px;
|
|
||||||
}
|
|
||||||
.mat-cell {
|
|
||||||
min-width: 100px;
|
|
||||||
padding-top: 2px;
|
|
||||||
padding-right: 8px;
|
|
||||||
}
|
|
||||||
.selection {
|
|
||||||
min-width: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.import-done {
|
|
||||||
background-color: #cfc;
|
|
||||||
}
|
|
||||||
.import-error {
|
|
||||||
background-color: #fcc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.code {
|
|
||||||
padding-left: 1em;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.wrapper {
|
|
||||||
display: flex;
|
|
||||||
vertical-align: bottom;
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.summary {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-input {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.newBadge {
|
|
||||||
margin-left: -3px;
|
|
||||||
}
|
|
@ -1,100 +1,21 @@
|
|||||||
import { MatTableDataSource, MatTable, MatSnackBar, MatSelectChange } from '@angular/material';
|
import { Component } from '@angular/core';
|
||||||
|
import { MatSnackBar } from '@angular/material';
|
||||||
import { Title } from '@angular/platform-browser';
|
import { Title } from '@angular/platform-browser';
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import { ViewChild, Component, OnInit } from '@angular/core';
|
|
||||||
|
|
||||||
import { BaseViewComponent } from 'app/site/base/base-view';
|
import { BaseImportListComponent } from 'app/site/base/base-import-list';
|
||||||
import { MotionCsvExportService } from '../../services/motion-csv-export.service';
|
import { MotionCsvExportService } from '../../services/motion-csv-export.service';
|
||||||
import { MotionImportService, NewMotionEntry, ValueLabelCombination } from '../../services/motion-import.service';
|
import { MotionImportService } from '../../services/motion-import.service';
|
||||||
|
import { ViewMotion } from '../../models/view-motion';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component for the motion import list view.
|
* Component for the motion import list view.
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'os-motion-import-list',
|
selector: 'os-motion-import-list',
|
||||||
templateUrl: './motion-import-list.component.html',
|
templateUrl: './motion-import-list.component.html'
|
||||||
styleUrls: ['./motion-import-list.component.scss']
|
|
||||||
})
|
})
|
||||||
export class MotionImportListComponent extends BaseViewComponent implements OnInit {
|
export class MotionImportListComponent extends BaseImportListComponent<ViewMotion> {
|
||||||
/**
|
|
||||||
* The data source for a table. Requires to be initialised with a BaseViewModel
|
|
||||||
*/
|
|
||||||
public dataSource: MatTableDataSource<NewMotionEntry>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Switch that turns true if a file has been selected in the input
|
|
||||||
*/
|
|
||||||
public hasFile = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Currently selected encoding. Is set and changed by the config's available
|
|
||||||
* encodings and user mat-select input
|
|
||||||
*/
|
|
||||||
public selectedEncoding = 'utf-8';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* indicator on which elements to display
|
|
||||||
*/
|
|
||||||
public shown: 'all' | 'error' | 'noerror' = 'all';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The table itself
|
|
||||||
*/
|
|
||||||
@ViewChild(MatTable)
|
|
||||||
protected table: MatTable<NewMotionEntry>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the amount of total item successfully parsed
|
|
||||||
*/
|
|
||||||
public get totalCount(): number {
|
|
||||||
return this.importer && this.hasFile ? this.importer.summary.total : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the available text separators and their labels
|
|
||||||
*/
|
|
||||||
public get textSeparators(): ValueLabelCombination[] {
|
|
||||||
return this.importer.textSeparators;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the amount of import items that will be imported
|
|
||||||
*/
|
|
||||||
public get newCount(): number {
|
|
||||||
return this.importer && this.hasFile ? this.importer.summary.new : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the number of import items that cannot be imported
|
|
||||||
*/
|
|
||||||
public get nonImportableCount(): number {
|
|
||||||
if (this.importer && this.hasFile) {
|
|
||||||
return this.importer.summary.errors + this.importer.summary.duplicates;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the number of import items that have been successfully imported
|
|
||||||
*/
|
|
||||||
public get doneCount(): number {
|
|
||||||
return this.importer && this.hasFile ? this.importer.summary.done : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructor for list view bases
|
* Constructor for list view bases
|
||||||
*
|
*
|
||||||
@ -107,101 +28,11 @@ export class MotionImportListComponent extends BaseViewComponent implements OnIn
|
|||||||
public constructor(
|
public constructor(
|
||||||
titleService: Title,
|
titleService: Title,
|
||||||
matSnackBar: MatSnackBar,
|
matSnackBar: MatSnackBar,
|
||||||
public translate: TranslateService,
|
translate: TranslateService,
|
||||||
private importer: MotionImportService,
|
importer: MotionImportService,
|
||||||
private motionCSVExport: MotionCsvExportService
|
private motionCSVExport: MotionCsvExportService
|
||||||
) {
|
) {
|
||||||
super(titleService, translate, matSnackBar);
|
super(importer, titleService, translate, matSnackBar);
|
||||||
this.initTable();
|
|
||||||
this.importer.errorEvent.subscribe(this.raiseError);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts with a clean preview (removing any previously existing import previews)
|
|
||||||
*/
|
|
||||||
public ngOnInit(): void {
|
|
||||||
this.importer.clearPreview();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the table
|
|
||||||
*/
|
|
||||||
public initTable(): void {
|
|
||||||
this.dataSource = new MatTableDataSource();
|
|
||||||
this.setFilter();
|
|
||||||
this.importer.getNewEntries().subscribe(newEntries => {
|
|
||||||
this.dataSource.data = newEntries;
|
|
||||||
this.hasFile = newEntries.length > 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the table column definition. Fetches all headers from
|
|
||||||
* {@link MotionImportService} and an additional status column
|
|
||||||
*/
|
|
||||||
public getColumnDefinition(): string[] {
|
|
||||||
return ['status'].concat(this.importer.expectedHeader);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<void> {
|
|
||||||
await this.importer.doImport();
|
|
||||||
this.setFilter();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates and manually triggers the filter function.
|
|
||||||
* See {@link hidden} for options
|
|
||||||
* (changed from default mat-table filter)
|
|
||||||
*/
|
|
||||||
public setFilter(): void {
|
|
||||||
this.dataSource.filter = '';
|
|
||||||
if (this.shown === 'all') {
|
|
||||||
this.dataSource.filterPredicate = (data, filter) => {
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
} else if (this.shown === 'noerror') {
|
|
||||||
this.dataSource.filterPredicate = (data, filter) => {
|
|
||||||
if (data.newMotion.status === 'done') {
|
|
||||||
return true;
|
|
||||||
} else if (!(data.newMotion.status !== 'error') && !data.duplicates.length) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} else if (this.shown === 'error') {
|
|
||||||
this.dataSource.filterPredicate = (data, filter) => {
|
|
||||||
if (data.newMotion.errors.length || data.duplicates.length) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
this.dataSource.filter = 'X'; // TODO: This is just a bogus non-null string to trigger the filter
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the appropiate css class for a row according to the import state
|
|
||||||
*
|
|
||||||
* @param row
|
|
||||||
*/
|
|
||||||
public getStateClass(row: NewMotionEntry): string {
|
|
||||||
switch (row.newMotion.status) {
|
|
||||||
case 'done':
|
|
||||||
return 'import-done import-decided';
|
|
||||||
case 'error':
|
|
||||||
return 'import-error';
|
|
||||||
default:
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -233,24 +64,6 @@ export class MotionImportListComponent extends BaseViewComponent implements OnIn
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the icon for the action of the item
|
|
||||||
* @param entry
|
|
||||||
*/
|
|
||||||
public getActionIcon(entry: NewMotionEntry): string {
|
|
||||||
switch (entry.newMotion.status) {
|
|
||||||
case 'error': // no import possible
|
|
||||||
return 'block';
|
|
||||||
case 'new': // new item, will be imported
|
|
||||||
return 'playlist_add';
|
|
||||||
case 'done': // item has been imported
|
|
||||||
return 'done';
|
|
||||||
default:
|
|
||||||
// fallback: Error
|
|
||||||
return 'block';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to remove html tags from a string.
|
* Helper to remove html tags from a string.
|
||||||
* CAUTION: It is just a basic "don't show distracting html tags in a
|
* CAUTION: It is just a basic "don't show distracting html tags in a
|
||||||
@ -268,41 +81,4 @@ export class MotionImportListComponent extends BaseViewComponent implements OnIn
|
|||||||
public downloadCsvExample(): void {
|
public downloadCsvExample(): void {
|
||||||
this.motionCSVExport.exportDummyMotion();
|
this.motionCSVExport.exportDummyMotion();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Trigger for the column separator selection
|
|
||||||
*
|
|
||||||
* @param event
|
|
||||||
*/
|
|
||||||
public selectColSep(event: MatSelectChange): void {
|
|
||||||
this.importer.columnSeparator = event.value;
|
|
||||||
this.importer.refreshFile();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trigger for the column separator selection
|
|
||||||
*
|
|
||||||
* @param event
|
|
||||||
*/
|
|
||||||
public selectTextSep(event: MatSelectChange): void {
|
|
||||||
this.importer.textSeparator = event.value;
|
|
||||||
this.importer.refreshFile();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trigger for the encoding selection
|
|
||||||
*
|
|
||||||
* @param event
|
|
||||||
*/
|
|
||||||
public selectEncoding(event: MatSelectChange): void {
|
|
||||||
this.importer.encoding = event.value;
|
|
||||||
this.importer.refreshFile();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a descriptive string for an import error
|
|
||||||
*/
|
|
||||||
public getVerboseError(error: string): string {
|
|
||||||
return this.importer.verbose(error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -2,9 +2,8 @@ import { ViewCreateMotion } from './view-create-motion';
|
|||||||
import { CreateMotion } from './create-motion';
|
import { CreateMotion } from './create-motion';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface for imported secondary data. A name can be matched to an existing
|
* Interface for correlating between strings representing BaseModels and existing
|
||||||
* model instance by the solve... functions.
|
* BaseModels.
|
||||||
* TODO MultiId will be filled if there is more than one match (to be used in case of 'I want to select one of these matches)
|
|
||||||
*/
|
*/
|
||||||
export interface CsvMapping {
|
export interface CsvMapping {
|
||||||
name: string;
|
name: string;
|
||||||
@ -12,8 +11,6 @@ export interface CsvMapping {
|
|||||||
multiId?: number[];
|
multiId?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type CsvImportStatus = 'new' | 'error' | 'done';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create motion class for the View. Its different to ViewMotion in fact that the submitter handling is different
|
* Create motion class for the View. Its different to ViewMotion in fact that the submitter handling is different
|
||||||
* on motion creation.
|
* on motion creation.
|
||||||
@ -38,68 +35,10 @@ export class ViewCsvCreateMotion extends ViewCreateMotion {
|
|||||||
*/
|
*/
|
||||||
public csvSubmitters: CsvMapping[];
|
public csvSubmitters: CsvMapping[];
|
||||||
|
|
||||||
/**
|
|
||||||
* The current import status of this motion.
|
|
||||||
* Starts as 'new', if set to 'done', a proper {@link Motion} model will
|
|
||||||
* probably exist in the dataStore. error status will be set if the import
|
|
||||||
* cannot be done
|
|
||||||
*/
|
|
||||||
private _status: CsvImportStatus = 'new';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* list of import errors See {@link MotionImportService}
|
|
||||||
*/
|
|
||||||
public errors: string[] = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the current status.
|
|
||||||
*/
|
|
||||||
public get status(): CsvImportStatus {
|
|
||||||
return this._status;
|
|
||||||
}
|
|
||||||
|
|
||||||
public set status(status: CsvImportStatus) {
|
|
||||||
this._status = status;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get motion(): CreateMotion {
|
|
||||||
return this._motion;
|
|
||||||
}
|
|
||||||
|
|
||||||
public constructor(motion?: CreateMotion) {
|
public constructor(motion?: CreateMotion) {
|
||||||
super(motion);
|
super(motion);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Duplicate this motion into a copy of itself
|
|
||||||
*/
|
|
||||||
public copy(): ViewCreateMotion {
|
|
||||||
return new ViewCreateMotion(
|
|
||||||
this._motion,
|
|
||||||
this._category,
|
|
||||||
this._submitters,
|
|
||||||
this._supporters,
|
|
||||||
this._workflow,
|
|
||||||
this._state
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a given error is present. TODO: Is more a ViewModel option
|
|
||||||
*
|
|
||||||
* @param error
|
|
||||||
*/
|
|
||||||
public hasError(error: string): boolean {
|
|
||||||
return this.errors.includes(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle to set a CreateMotion to a 'successfully parsed' status
|
|
||||||
*/
|
|
||||||
public done(): void {
|
|
||||||
this._status = 'done';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* takes a list of motion block mappings to update the current csvMotionblock.
|
* takes a list of motion block mappings to update the current csvMotionblock.
|
||||||
* Returns the amount of entries that remain unmatched
|
* Returns the amount of entries that remain unmatched
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { BehaviorSubject, Observable } from 'rxjs';
|
import { Injectable } from '@angular/core';
|
||||||
import { Injectable, EventEmitter } from '@angular/core';
|
|
||||||
import { MatSnackBar } from '@angular/material';
|
import { MatSnackBar } from '@angular/material';
|
||||||
import { Papa, PapaParseConfig } from 'ngx-papaparse';
|
import { Papa } from 'ngx-papaparse';
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
|
||||||
import { Category } from 'app/shared/models/motions/category';
|
import { Category } from 'app/shared/models/motions/category';
|
||||||
@ -11,49 +10,9 @@ import { MotionBlock } from 'app/shared/models/motions/motion-block';
|
|||||||
import { MotionBlockRepositoryService } from './motion-block-repository.service';
|
import { MotionBlockRepositoryService } from './motion-block-repository.service';
|
||||||
import { MotionRepositoryService } from './motion-repository.service';
|
import { MotionRepositoryService } from './motion-repository.service';
|
||||||
import { UserRepositoryService } from '../../users/services/user-repository.service';
|
import { UserRepositoryService } from '../../users/services/user-repository.service';
|
||||||
import { ViewMotion } from '../models/view-motion';
|
|
||||||
import { ViewCsvCreateMotion, CsvMapping } from '../models/view-csv-create-motion';
|
import { ViewCsvCreateMotion, CsvMapping } from '../models/view-csv-create-motion';
|
||||||
|
import { BaseImportService, NewEntry } from 'app/core/services/base-import.service';
|
||||||
/**
|
import { ViewMotion } from '../models/view-motion';
|
||||||
* Interface for value- Label combinations.
|
|
||||||
* Map objects didn't work, TODO: Use map objects (needs iterating through all objects of a map)
|
|
||||||
*/
|
|
||||||
export interface ValueLabelCombination {
|
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface for a new Motion and their (if any) duplicates
|
|
||||||
*/
|
|
||||||
export interface NewMotionEntry {
|
|
||||||
newMotion: ViewCsvCreateMotion;
|
|
||||||
duplicates: ViewMotion[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* interface for a preview summary
|
|
||||||
*/
|
|
||||||
interface ImportMotionCSVPreview {
|
|
||||||
total: number;
|
|
||||||
duplicates: number;
|
|
||||||
errors: number;
|
|
||||||
new: number;
|
|
||||||
done: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List of possible import errors specific for motion imports.
|
|
||||||
*/
|
|
||||||
const errorList = {
|
|
||||||
MotionBlock: 'Could not resolve the motion block',
|
|
||||||
Category: 'Could not resolve the category',
|
|
||||||
Submitters: 'Could not resolve the submitters',
|
|
||||||
Title: 'A title is required',
|
|
||||||
Text: "A content in the 'text' column is required",
|
|
||||||
Duplicates: 'A motion with this identifier already exists.',
|
|
||||||
generic: 'Server upload failed' // TODO
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service for motion imports
|
* Service for motion imports
|
||||||
@ -61,59 +20,23 @@ const errorList = {
|
|||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class MotionImportService {
|
export class MotionImportService extends BaseImportService<ViewMotion> {
|
||||||
/** The header (order and items) that is expected from the imported file
|
/**
|
||||||
*
|
* List of possible errors and their verbose explanation
|
||||||
*/
|
*/
|
||||||
public expectedHeader = [
|
public errorList = {
|
||||||
'identifier',
|
MotionBlock: 'Could not resolve the motion block',
|
||||||
'title',
|
Category: 'Could not resolve the category',
|
||||||
'text',
|
Submitters: 'Could not resolve the submitters',
|
||||||
'reason',
|
Title: 'A title is required',
|
||||||
'submitters',
|
Text: "A content in the 'text' column is required",
|
||||||
'category',
|
Duplicates: 'A motion with this identifier already exists.'
|
||||||
'origin',
|
};
|
||||||
'motion block'
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The last parsed file object (may be reparsed with new encoding, thus kept in memory)
|
* The minimimal number of header entries needed to successfully create an entry
|
||||||
*/
|
*/
|
||||||
private _rawFile: File;
|
public requiredHeaderLength = 3;
|
||||||
|
|
||||||
/**
|
|
||||||
* The used column Separator. If left on an empty string (default),
|
|
||||||
* the papaparse parser will automatically decide on separators.
|
|
||||||
*/
|
|
||||||
public columnSeparator = '';
|
|
||||||
|
|
||||||
public textSeparator = '"';
|
|
||||||
|
|
||||||
public encoding = 'utf-8';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List of possible encodings and their label
|
|
||||||
*/
|
|
||||||
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
|
|
||||||
*/
|
|
||||||
public columnSeparators: ValueLabelCombination[] = [
|
|
||||||
{ label: 'Comma', value: ',' },
|
|
||||||
{ label: 'Semicolon', value: ';' },
|
|
||||||
// {label: 'Tabulator', value: '\t'},
|
|
||||||
{ label: 'Automatic', value: '' }
|
|
||||||
];
|
|
||||||
|
|
||||||
public textSeparators: ValueLabelCombination[] = [
|
|
||||||
{ label: 'Double quotes (")', value: '"' },
|
|
||||||
{ label: "Single quotes (')", value: "'" }
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* submitters that need to be created prior to importing
|
* submitters that need to be created prior to importing
|
||||||
@ -131,44 +54,8 @@ export class MotionImportService {
|
|||||||
public newMotionBlocks: CsvMapping[] = [];
|
public newMotionBlocks: CsvMapping[] = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FileReader object for file import
|
* Constructor. Defines the headers expected and calls the abstract class
|
||||||
*/
|
* @param repo: The repository for motions.
|
||||||
private reader = new FileReader();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* the list of parsed models that have been extracted from the opened file
|
|
||||||
*/
|
|
||||||
private _entries: NewMotionEntry[] = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* BehaviorSubject for displaying a preview for the currently selected entries
|
|
||||||
*/
|
|
||||||
public newEntries = new BehaviorSubject<NewMotionEntry[]>([]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emits an error string to display if a file import cannot be done
|
|
||||||
*/
|
|
||||||
public errorEvent = new EventEmitter<string>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* storing the summary preview for the import, to avoid recalculating it
|
|
||||||
* at each display change.
|
|
||||||
*/
|
|
||||||
private _preview: ImportMotionCSVPreview;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a summary on actions that will be taken/not taken.
|
|
||||||
*/
|
|
||||||
public get summary(): ImportMotionCSVPreview {
|
|
||||||
if (!this._preview) {
|
|
||||||
this.updatePreview();
|
|
||||||
}
|
|
||||||
return this._preview;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Constructor. Creates a fileReader to subscribe to it for incoming parsed
|
|
||||||
* strings
|
|
||||||
* @param categoryRepo Repository to fetch pre-existing categories
|
* @param categoryRepo Repository to fetch pre-existing categories
|
||||||
* @param motionBlockRepo Repository to fetch pre-existing motionBlocks
|
* @param motionBlockRepo Repository to fetch pre-existing motionBlocks
|
||||||
* @param userRepo Repository to query/ create users
|
* @param userRepo Repository to query/ create users
|
||||||
@ -181,158 +68,112 @@ export class MotionImportService {
|
|||||||
private categoryRepo: CategoryRepositoryService,
|
private categoryRepo: CategoryRepositoryService,
|
||||||
private motionBlockRepo: MotionBlockRepositoryService,
|
private motionBlockRepo: MotionBlockRepositoryService,
|
||||||
private userRepo: UserRepositoryService,
|
private userRepo: UserRepositoryService,
|
||||||
private translate: TranslateService,
|
translate: TranslateService,
|
||||||
private papa: Papa,
|
papa: Papa,
|
||||||
private matSnackbar: MatSnackBar
|
matSnackbar: MatSnackBar
|
||||||
) {
|
) {
|
||||||
this.reader.onload = (event: any) => {
|
super(translate, papa, matSnackbar);
|
||||||
// TODO type: event is a progressEvent,
|
|
||||||
// but has a property target.result, which typescript doesn't recognize
|
this.expectedHeader = [
|
||||||
this.parseInput(event.target.result);
|
'identifier',
|
||||||
};
|
'title',
|
||||||
|
'text',
|
||||||
|
'reason',
|
||||||
|
'submitters',
|
||||||
|
'category',
|
||||||
|
'origin',
|
||||||
|
'motion_block'
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses the data input. Expects a string as returned by via a
|
* Clears all temporary data specific to this importer.
|
||||||
* File.readAsText() operation
|
|
||||||
*
|
|
||||||
* @param file
|
|
||||||
*/
|
*/
|
||||||
public parseInput(file: string): void {
|
public clearData(): void {
|
||||||
this._entries = [];
|
|
||||||
this.newSubmitters = [];
|
this.newSubmitters = [];
|
||||||
this.newCategories = [];
|
this.newCategories = [];
|
||||||
this.newMotionBlocks = [];
|
this.newMotionBlocks = [];
|
||||||
const papaConfig: PapaParseConfig = {
|
|
||||||
header: false,
|
|
||||||
skipEmptyLines: true,
|
|
||||||
quoteChar: this.textSeparator
|
|
||||||
};
|
|
||||||
if (this.columnSeparator) {
|
|
||||||
papaConfig.delimiter = this.columnSeparator;
|
|
||||||
}
|
|
||||||
const entryLines = this.papa.parse(file, papaConfig).data;
|
|
||||||
const valid = this.checkHeader(entryLines.shift());
|
|
||||||
if (!valid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
entryLines.forEach(line => {
|
|
||||||
const newMotion = new ViewCsvCreateMotion(new CreateMotion());
|
|
||||||
const headerLength = Math.min(this.expectedHeader.length, line.length);
|
|
||||||
for (let idx = 0; idx < headerLength; idx++) {
|
|
||||||
// iterate over items, find existing ones (thier id) and collect new entries
|
|
||||||
switch (this.expectedHeader[idx]) {
|
|
||||||
case 'submitters':
|
|
||||||
newMotion.csvSubmitters = this.getSubmitters(line[idx]);
|
|
||||||
break;
|
|
||||||
case 'category':
|
|
||||||
newMotion.csvCategory = this.getCategory(line[idx]);
|
|
||||||
break;
|
|
||||||
case 'motion block':
|
|
||||||
newMotion.csvMotionblock = this.getMotionBlock(line[idx]);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
newMotion.motion[this.expectedHeader[idx]] = line[idx];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const updateModels = this.getDuplicates(newMotion.motion);
|
|
||||||
if (updateModels.length) {
|
|
||||||
this.setError(newMotion, 'Duplicates');
|
|
||||||
}
|
|
||||||
this._entries.push({ newMotion: newMotion, duplicates: updateModels });
|
|
||||||
});
|
|
||||||
this.newEntries.next(this._entries);
|
|
||||||
this.updatePreview();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Triggers the import.
|
* Parses a string representing an entry, extracting secondary data, appending
|
||||||
|
* the array of secondary imports as needed
|
||||||
|
*
|
||||||
|
* @param line
|
||||||
|
* @returns a new Entry representing a Motion
|
||||||
|
*/
|
||||||
|
public mapData(line: string): NewEntry<ViewMotion> {
|
||||||
|
const newEntry = new ViewCsvCreateMotion(new CreateMotion());
|
||||||
|
const headerLength = Math.min(this.expectedHeader.length, line.length);
|
||||||
|
for (let idx = 0; idx < headerLength; idx++) {
|
||||||
|
switch (this.expectedHeader[idx]) {
|
||||||
|
case 'submitters':
|
||||||
|
newEntry.csvSubmitters = this.getSubmitters(line[idx]);
|
||||||
|
break;
|
||||||
|
case 'category':
|
||||||
|
newEntry.csvCategory = this.getCategory(line[idx]);
|
||||||
|
break;
|
||||||
|
case 'motion_block':
|
||||||
|
newEntry.csvMotionblock = this.getMotionBlock(line[idx]);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
newEntry.motion[this.expectedHeader[idx]] = line[idx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const updateModels = this.repo.getMotionDuplicates(newEntry);
|
||||||
|
return {
|
||||||
|
newEntry: newEntry,
|
||||||
|
duplicates: updateModels,
|
||||||
|
status: updateModels.length ? 'error' : 'new',
|
||||||
|
errors: updateModels.length ? ['Duplicates'] : []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes the import. Creates all secondary data, maps the newly created
|
||||||
|
* secondary data to the new entries, then creates all entries without errors
|
||||||
|
* by submitting them to the server. The entries will receive the status
|
||||||
|
* 'done' on success.
|
||||||
*/
|
*/
|
||||||
public async doImport(): Promise<void> {
|
public async doImport(): Promise<void> {
|
||||||
this.newMotionBlocks = await this.createNewMotionBlocks();
|
this.newMotionBlocks = await this.createNewMotionBlocks();
|
||||||
this.newCategories = await this.createNewCategories();
|
this.newCategories = await this.createNewCategories();
|
||||||
this.newSubmitters = await this.createNewUsers();
|
this.newSubmitters = await this.createNewUsers();
|
||||||
|
|
||||||
for (const entry of this._entries) {
|
for (const entry of this.entries) {
|
||||||
if (entry.newMotion.status !== 'new') {
|
if (entry.status !== 'new') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const openBlocks = entry.newMotion.solveMotionBlocks(this.newMotionBlocks);
|
const openBlocks = (entry.newEntry as ViewCsvCreateMotion).solveMotionBlocks(this.newMotionBlocks);
|
||||||
if (openBlocks) {
|
if (openBlocks) {
|
||||||
this.setError(entry.newMotion, 'MotionBlock');
|
this.setError(entry, 'MotionBlock');
|
||||||
// TODO error handling if not all submitters could be matched
|
|
||||||
this.updatePreview();
|
this.updatePreview();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const openCategories = entry.newMotion.solveCategory(this.newCategories);
|
const openCategories = (entry.newEntry as ViewCsvCreateMotion).solveCategory(this.newCategories);
|
||||||
if (openCategories) {
|
if (openCategories) {
|
||||||
this.setError(entry.newMotion, 'Category');
|
this.setError(entry, 'Category');
|
||||||
this.updatePreview();
|
this.updatePreview();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const openUsers = entry.newMotion.solveSubmitters(this.newSubmitters);
|
const openUsers = (entry.newEntry as ViewCsvCreateMotion).solveSubmitters(this.newSubmitters);
|
||||||
if (openUsers) {
|
if (openUsers) {
|
||||||
this.setError(entry.newMotion, 'Submitters');
|
this.setError(entry, 'Submitters');
|
||||||
this.updatePreview();
|
this.updatePreview();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await this.repo.create(entry.newMotion.motion);
|
await this.repo.create((entry.newEntry as ViewCsvCreateMotion).motion);
|
||||||
entry.newMotion.done();
|
entry.status = 'done';
|
||||||
}
|
}
|
||||||
this.updatePreview();
|
this.updatePreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks the dataStore for duplicates
|
|
||||||
* @returns an array of duplicates with the same identifier.
|
|
||||||
* @param motion
|
|
||||||
*/
|
|
||||||
public getDuplicates(motion: CreateMotion): ViewMotion[] {
|
|
||||||
return this.repo.getMotionDuplicates(motion);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* counts the amount of duplicates that have no decision on the action to
|
|
||||||
* be taken
|
|
||||||
*/
|
|
||||||
public updatePreview(): void {
|
|
||||||
const summary = {
|
|
||||||
total: 0,
|
|
||||||
new: 0,
|
|
||||||
duplicates: 0,
|
|
||||||
errors: 0,
|
|
||||||
done: 0
|
|
||||||
};
|
|
||||||
this._entries.forEach(entry => {
|
|
||||||
summary.total += 1;
|
|
||||||
if (entry.newMotion.status === 'done') {
|
|
||||||
summary.done += 1;
|
|
||||||
return;
|
|
||||||
} else if (entry.newMotion.status === 'error' && !entry.duplicates.length) {
|
|
||||||
// errors that are not due to duplicates
|
|
||||||
summary.errors += 1;
|
|
||||||
return;
|
|
||||||
} else if (entry.duplicates.length) {
|
|
||||||
summary.duplicates += 1;
|
|
||||||
return;
|
|
||||||
} else if (entry.newMotion.status === 'new') {
|
|
||||||
summary.new += 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this._preview = summary;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* returns a subscribable representation of the new Users to be imported
|
|
||||||
*/
|
|
||||||
public getNewEntries(): Observable<NewMotionEntry[]> {
|
|
||||||
return this.newEntries.asObservable();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks the provided submitter(s) and returns an object with mapping of
|
* Checks the provided submitter(s) and returns an object with mapping of
|
||||||
* existing users and of users that need to be created
|
* existing users and of users that need to be created
|
||||||
|
*
|
||||||
* @param submitterlist
|
* @param submitterlist
|
||||||
|
* @returns a list of submitters mapped with (if already existing) their id
|
||||||
*/
|
*/
|
||||||
public getSubmitters(submitterlist: string): CsvMapping[] {
|
public getSubmitters(submitterlist: string): CsvMapping[] {
|
||||||
const result: CsvMapping[] = [];
|
const result: CsvMapping[] = [];
|
||||||
@ -375,7 +216,9 @@ export class MotionImportService {
|
|||||||
* characters at the beginning, separated by ' - ' from the name.
|
* characters at the beginning, separated by ' - ' from the name.
|
||||||
* It will also accept a registered translation between the current user's
|
* It will also accept a registered translation between the current user's
|
||||||
* language and english
|
* language and english
|
||||||
|
*
|
||||||
* @param categoryString
|
* @param categoryString
|
||||||
|
* @returns categories mapped to existing categories
|
||||||
*/
|
*/
|
||||||
public getCategory(categoryString: string): CsvMapping {
|
public getCategory(categoryString: string): CsvMapping {
|
||||||
if (!categoryString) {
|
if (!categoryString) {
|
||||||
@ -411,7 +254,9 @@ export class MotionImportService {
|
|||||||
* Checks the motionBlock provided in the string for existance, expands newMotionBlocks
|
* Checks the motionBlock provided in the string for existance, expands newMotionBlocks
|
||||||
* if needed. Note that it will also check for translation between the current
|
* if needed. Note that it will also check for translation between the current
|
||||||
* user's language and english
|
* user's language and english
|
||||||
|
*
|
||||||
* @param blockString
|
* @param blockString
|
||||||
|
* @returns a CSVMap with the MotionBlock and an id (if the motionBlock is already in the dataStore)
|
||||||
*/
|
*/
|
||||||
public getMotionBlock(blockString: string): CsvMapping {
|
public getMotionBlock(blockString: string): CsvMapping {
|
||||||
if (!blockString) {
|
if (!blockString) {
|
||||||
@ -434,6 +279,8 @@ export class MotionImportService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates all new Users needed for the import.
|
* Creates all new Users needed for the import.
|
||||||
|
*
|
||||||
|
* @returns a promise with list of new Submitters, updated with newly created ids
|
||||||
*/
|
*/
|
||||||
private async createNewUsers(): Promise<CsvMapping[]> {
|
private async createNewUsers(): Promise<CsvMapping[]> {
|
||||||
const promises: Promise<CsvMapping>[] = [];
|
const promises: Promise<CsvMapping>[] = [];
|
||||||
@ -445,6 +292,8 @@ export class MotionImportService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates all new Motion Blocks needed for the import.
|
* Creates all new Motion Blocks needed for the import.
|
||||||
|
*
|
||||||
|
* @returns a promise with list of new MotionBlocks, updated with newly created ids
|
||||||
*/
|
*/
|
||||||
private async createNewMotionBlocks(): Promise<CsvMapping[]> {
|
private async createNewMotionBlocks(): Promise<CsvMapping[]> {
|
||||||
const promises: Promise<CsvMapping>[] = [];
|
const promises: Promise<CsvMapping>[] = [];
|
||||||
@ -460,6 +309,8 @@ export class MotionImportService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates all new Categories needed for the import.
|
* Creates all new Categories needed for the import.
|
||||||
|
*
|
||||||
|
* @returns a promise with list of new Categories, updated with newly created ids
|
||||||
*/
|
*/
|
||||||
private async createNewCategories(): Promise<CsvMapping[]> {
|
private async createNewCategories(): Promise<CsvMapping[]> {
|
||||||
const promises: Promise<CsvMapping>[] = [];
|
const promises: Promise<CsvMapping>[] = [];
|
||||||
@ -481,105 +332,12 @@ export class MotionImportService {
|
|||||||
return await Promise.all(promises);
|
return await Promise.all(promises);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 {
|
|
||||||
// TODO type
|
|
||||||
if (event.target.files && event.target.files.length === 1) {
|
|
||||||
if (event.target.files[0].type === 'text/csv') {
|
|
||||||
this._rawFile = event.target.files[0];
|
|
||||||
this.readFile(event.target.files[0]);
|
|
||||||
} else {
|
|
||||||
this.matSnackbar.open(this.translate.instant('Wrong file type detected. Import failed.'), '', {
|
|
||||||
duration: 3000
|
|
||||||
});
|
|
||||||
this.clearPreview();
|
|
||||||
this._rawFile = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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(this._rawFile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* (re)-reads a given file with the current parameter
|
|
||||||
*/
|
|
||||||
private readFile(file: File): void {
|
|
||||||
this.reader.readAsText(file, this.encoding);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks the first line of the csv (the header) for consistency (length)
|
|
||||||
* @param row expected to be an array parsed from the first line of a csv file
|
|
||||||
*/
|
|
||||||
private checkHeader(row: string[]): boolean {
|
|
||||||
const snackbarDuration = 3000;
|
|
||||||
if (row.length < 4) {
|
|
||||||
this.matSnackbar.open(this.translate.instant('The file has too few columns to be parsed properly.'), '', {
|
|
||||||
duration: snackbarDuration
|
|
||||||
});
|
|
||||||
|
|
||||||
this.clearPreview();
|
|
||||||
return false;
|
|
||||||
} else if (row.length < this.expectedHeader.length) {
|
|
||||||
this.matSnackbar.open(
|
|
||||||
this.translate.instant('The file seems to have some ommitted columns. They will be considered empty.'),
|
|
||||||
'',
|
|
||||||
{ duration: snackbarDuration }
|
|
||||||
);
|
|
||||||
} else if (row.length > this.expectedHeader.length) {
|
|
||||||
this.matSnackbar.open(
|
|
||||||
this.translate.instant('The file seems to have additional columns. They will be ignored.'),
|
|
||||||
'',
|
|
||||||
{ duration: snackbarDuration }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resets the data and preview (triggered upon selecting an invalid file)
|
|
||||||
*/
|
|
||||||
public clearPreview(): void {
|
|
||||||
this._entries = [];
|
|
||||||
this.newEntries.next([]);
|
|
||||||
this._preview = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* set a list of short names for error, indicating which column failed
|
|
||||||
*/
|
|
||||||
public setError(motion: ViewCsvCreateMotion, error: string): void {
|
|
||||||
if (errorList.hasOwnProperty(error) && !motion.errors.includes(error)) {
|
|
||||||
motion.errors.push(error);
|
|
||||||
motion.status = 'error';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get an extended error description.
|
|
||||||
* @param error
|
|
||||||
*/
|
|
||||||
public verbose(error: string): string {
|
|
||||||
return errorList[error];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to separate a category string from its' prefix. Assumes that a prefix is no longer
|
* Helper to separate a category string from its' prefix. Assumes that a prefix is no longer
|
||||||
* than 5 chars and separated by a ' - '
|
* than 5 chars and separated by a ' - '
|
||||||
|
*
|
||||||
* @param categoryString the string to parse
|
* @param categoryString the string to parse
|
||||||
|
* @returns an object with .prefix and .name strings
|
||||||
*/
|
*/
|
||||||
private splitCategoryString(categoryString: string): { prefix: string; name: string } {
|
private splitCategoryString(categoryString: string): { prefix: string; name: string } {
|
||||||
let prefixSeparator = ' - ';
|
let prefixSeparator = ' - ';
|
||||||
|
@ -607,12 +607,13 @@ export class MotionRepositoryService extends BaseRepository<ViewMotion, Motion>
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns all Motion duplicates (sharing specific values given in input)
|
* Returns motion duplicates (sharing the identifier)
|
||||||
|
*
|
||||||
* @param viewMotion the ViewMotion to compare against the list of Motions
|
* @param viewMotion the ViewMotion to compare against the list of Motions
|
||||||
* in the data
|
* in the data
|
||||||
* @param sharedValues properties that must be equal to consider it a duplicate
|
* @returns An Array of ViewMotions with the same identifier of the input, or an empty array
|
||||||
*/
|
*/
|
||||||
public getMotionDuplicates(motion: Motion): ViewMotion[] {
|
public getMotionDuplicates(motion: ViewMotion): ViewMotion[] {
|
||||||
const duplicates = this.DS.filter(Motion, item => motion.identifier === item.identifier);
|
const duplicates = this.DS.filter(Motion, item => motion.identifier === item.identifier);
|
||||||
const viewMotions: ViewMotion[] = [];
|
const viewMotions: ViewMotion[] = [];
|
||||||
duplicates.forEach(item => viewMotions.push(this.createViewModel(item)));
|
duplicates.forEach(item => viewMotions.push(this.createViewModel(item)));
|
||||||
|
@ -0,0 +1,283 @@
|
|||||||
|
<os-head-bar [nav]="false">
|
||||||
|
<!-- Title -->
|
||||||
|
<div class="title-slot"><h2 translate>Import users</h2></div>
|
||||||
|
|
||||||
|
<div class="menu-slot">
|
||||||
|
<button *ngIf="hasFile && newCount" mat-button (click)="doImport()">
|
||||||
|
<span class="upper" translate> Import</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</os-head-bar>
|
||||||
|
<mat-card class="os-form-card import-table">
|
||||||
|
<mat-tab-group (selectedTabChange)="onTabChange()">
|
||||||
|
<mat-tab label="{{ 'CSV import' | translate }}">
|
||||||
|
<span translate
|
||||||
|
>Requires comma or semicolon separated values with these column header names in the first row</span
|
||||||
|
>: <br />
|
||||||
|
<div class="code red-warning-text">
|
||||||
|
<span translate>Title</span>, <span translate>Given name</span>, <span translate>Surname</span> ,
|
||||||
|
<span translate>Structure level</span>, <span translate>Participant number</span>,
|
||||||
|
<span translate>Groups</span> , <span translate>Comment</span>, <span translate>Is active</span>,
|
||||||
|
<span translate>Is present</span> , <span translate>Is committee</span>,
|
||||||
|
<span translate>Initial password</span>, <span translate>Email</span>
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
<li translate>
|
||||||
|
At least given name or surname have to be filled in. All other fields are optional and may be empty.
|
||||||
|
</li>
|
||||||
|
<li translate>
|
||||||
|
Additional columns after the required ones may be present and won't affect the import.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<button mat-button color="accent" (click)="downloadCsvExample()" translate>
|
||||||
|
Download CSV example file
|
||||||
|
</button>
|
||||||
|
<div class="wrapper">
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label translate>Encoding of the file</mat-label>
|
||||||
|
<mat-select
|
||||||
|
class="selection"
|
||||||
|
placeholder="translate.instant('Select encoding')"
|
||||||
|
(selectionChange)="selectEncoding($event)"
|
||||||
|
[value]="encodings[0].value"
|
||||||
|
>
|
||||||
|
<mat-option *ngFor="let option of encodings" [value]="option.value">
|
||||||
|
{{ option.label | translate }}
|
||||||
|
</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label translate> Column Separator</mat-label>
|
||||||
|
<mat-select class="selection" (selectionChange)="selectColSep($event)" value="">
|
||||||
|
<mat-option *ngFor="let option of columnSeparators" [value]="option.value">
|
||||||
|
{{ option.label | translate }}
|
||||||
|
</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label translate>Text separator</mat-label>
|
||||||
|
<mat-select class="selection" (selectionChange)="selectTextSep($event)" value=""">
|
||||||
|
<mat-option *ngFor="let option of textSeparators" [value]="option.value">
|
||||||
|
{{ option.label | translate }}
|
||||||
|
</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
id="user-import-file-input"
|
||||||
|
type="file"
|
||||||
|
class="hidden-input"
|
||||||
|
accept="text"
|
||||||
|
#fileInput
|
||||||
|
(change)="onSelectFile($event)"
|
||||||
|
/>
|
||||||
|
<button mat-button color="accent" onclick="document.getElementById('user-import-file-input').click()">
|
||||||
|
<span translate> Select file</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-tab>
|
||||||
|
<mat-tab label="{{ 'Text import' | translate }}">
|
||||||
|
<div [formGroup]="textAreaForm">
|
||||||
|
<div>
|
||||||
|
<span translate>
|
||||||
|
Copy and paste your participant names in this textbox.</span>
|
||||||
|
<span translate>
|
||||||
|
Keep each person in a single line.
|
||||||
|
</span><br />
|
||||||
|
<span translate> Comma separated names will be read as 'Surname(s), given name(s)'. </span>
|
||||||
|
</div>
|
||||||
|
<mat-form-field>
|
||||||
|
<textarea
|
||||||
|
matInput
|
||||||
|
formControlName="inputtext"
|
||||||
|
placeholder="{{ 'Insert users here' | translate }}"
|
||||||
|
cdkTextareaAutosize
|
||||||
|
cdkAutosizeMinRows="3"
|
||||||
|
cdkAutosizeMaxRows="10"
|
||||||
|
></textarea>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button mat-button color="accent" (click)="parseTextArea()"><span translate>Preview</span></button>
|
||||||
|
</div>
|
||||||
|
</mat-tab>
|
||||||
|
</mat-tab-group>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<!-- preview table -->
|
||||||
|
<mat-card *ngIf="hasFile" class="os-form-card import-table">
|
||||||
|
<h3 translate> Preview</h3>
|
||||||
|
<div class="summary">
|
||||||
|
<!-- new entries -->
|
||||||
|
<div *ngIf="newCount">
|
||||||
|
|
||||||
|
<mat-icon inline>playlist_add</mat-icon>
|
||||||
|
<span> {{ newCount }} </span> <span translate>User(s) will be imported.</span>
|
||||||
|
</div>
|
||||||
|
<!-- errors/duplicates -->
|
||||||
|
<div *ngIf="nonImportableCount" class="red-warning-text">
|
||||||
|
|
||||||
|
<mat-icon inline>warning</mat-icon>
|
||||||
|
<span> {{ nonImportableCount }} </span> <span translate>entries will be ommitted.</span>
|
||||||
|
</div>
|
||||||
|
<!-- have been imported -->
|
||||||
|
<div *ngIf="doneCount" class="green-text">
|
||||||
|
|
||||||
|
<mat-icon inline>done</mat-icon>
|
||||||
|
<span> {{ doneCount }} </span> <span translate>Users have been imported.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="newCount">
|
||||||
|
<span translate>Click on 'import' (right top corner) to import the new users.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<mat-select *ngIf="nonImportableCount" class="filter-imports" [(value)]="shown" (selectionChange)="setFilter()">
|
||||||
|
<mat-option value="all" translate> Show all </mat-option>
|
||||||
|
<mat-option value="error" translate> Show errors only </mat-option>
|
||||||
|
<mat-option value="noerror" translate> Show correct entries </mat-option>
|
||||||
|
</mat-select>
|
||||||
|
<div class="table-container">
|
||||||
|
<table mat-table class="on-transition-fade" [dataSource]="dataSource" matSort>
|
||||||
|
<!-- Status column -->
|
||||||
|
<ng-container matColumnDef="status" sticky>
|
||||||
|
|
||||||
|
<mat-header-cell *matHeaderCellDef class="first-column"></mat-header-cell>
|
||||||
|
<mat-cell *matCellDef="let entry" class="first-column">
|
||||||
|
<div *ngIf="entry.status === 'error'">
|
||||||
|
<mat-icon
|
||||||
|
class="red-warning-text"
|
||||||
|
matTooltip="{{ entry.errors.length }} {{ 'errors' | translate }}"
|
||||||
|
>
|
||||||
|
{{ getActionIcon(entry) }}
|
||||||
|
</mat-icon>
|
||||||
|
<mat-icon
|
||||||
|
color="warn"
|
||||||
|
*ngIf="hasError(entry, 'ParsingErrors')"
|
||||||
|
matTooltip="{{ getVerboseError('ParsingErrors') | translate }}"
|
||||||
|
>
|
||||||
|
warning
|
||||||
|
</mat-icon>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="entry.status === 'new'">
|
||||||
|
<mat-icon matTooltip="{{ 'User will be imported' | translate }}">
|
||||||
|
{{ getActionIcon(entry) }}
|
||||||
|
</mat-icon>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="entry.status === 'done'">
|
||||||
|
<mat-icon matTooltip="{{ 'User has been imported' | translate }}">
|
||||||
|
{{ getActionIcon(entry) }}
|
||||||
|
</mat-icon>
|
||||||
|
</div>
|
||||||
|
</mat-cell>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Title column -->
|
||||||
|
<ng-container matColumnDef="title">
|
||||||
|
<mat-header-cell *matHeaderCellDef translate>Title</mat-header-cell>
|
||||||
|
<mat-cell *matCellDef="let entry">
|
||||||
|
<span *ngIf="nameErrors(entry)">
|
||||||
|
<mat-icon color="warn" inline matTooltip="{{ nameErrors(entry) | translate }}">
|
||||||
|
warning
|
||||||
|
</mat-icon>
|
||||||
|
|
||||||
|
</span>
|
||||||
|
{{ entry.newEntry.title }}
|
||||||
|
</mat-cell>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- title column -->
|
||||||
|
<ng-container matColumnDef="first_name">
|
||||||
|
<mat-header-cell *matHeaderCellDef translate>Given name</mat-header-cell>
|
||||||
|
<mat-cell *matCellDef="let entry">
|
||||||
|
<span *ngIf="nameErrors(entry)">
|
||||||
|
<mat-icon color="warn" inline matTooltip="{{ nameErrors(entry) | translate }}">
|
||||||
|
warning
|
||||||
|
</mat-icon>
|
||||||
|
|
||||||
|
</span>
|
||||||
|
{{ entry.newEntry.first_name }}
|
||||||
|
</mat-cell>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="last_name">
|
||||||
|
<mat-header-cell *matHeaderCellDef translate>Surname</mat-header-cell>
|
||||||
|
<mat-cell *matCellDef="let entry">
|
||||||
|
<span *ngIf="nameErrors(entry)">
|
||||||
|
<mat-icon color="warn" inline matTooltip="{{ nameErrors(entry) | translate }}">
|
||||||
|
warning
|
||||||
|
</mat-icon>
|
||||||
|
|
||||||
|
</span>
|
||||||
|
{{ entry.newEntry.last_name }}
|
||||||
|
</mat-cell>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="structure_level">
|
||||||
|
<mat-header-cell *matHeaderCellDef translate>Structure level</mat-header-cell>
|
||||||
|
<mat-cell *matCellDef="let entry"> {{ entry.newEntry.structure_level }} </mat-cell>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="participant_number">
|
||||||
|
<mat-header-cell *matHeaderCellDef translate>Participant number</mat-header-cell>
|
||||||
|
<mat-cell *matCellDef="let entry"> {{ entry.newEntry.user.number }} </mat-cell>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- groups column -->
|
||||||
|
<ng-container matColumnDef="groups_id">
|
||||||
|
<mat-header-cell *matHeaderCellDef translate>Groups</mat-header-cell>
|
||||||
|
<mat-cell *matCellDef="let entry">
|
||||||
|
<div *ngIf="entry.newEntry.csvGroups.length">
|
||||||
|
<span *ngIf="hasError(entry, 'Groups')">
|
||||||
|
<mat-icon color="warn"matTooltip="{{ getVerboseError('Groups') | translate }}">
|
||||||
|
warning
|
||||||
|
</mat-icon>
|
||||||
|
</span>
|
||||||
|
<span *ngFor="let group of entry.newEntry.csvGroups">
|
||||||
|
{{ group.name }}
|
||||||
|
<mat-icon class="newBadge" color="accent" inline *ngIf="!group.id">add</mat-icon>
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</mat-cell>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="comment">
|
||||||
|
<mat-header-cell *matHeaderCellDef translate>Comment</mat-header-cell>
|
||||||
|
<mat-cell *matCellDef="let entry"> {{ entry.newEntry.comment }} </mat-cell>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="is_active">
|
||||||
|
<mat-header-cell *matHeaderCellDef translate>Is Active</mat-header-cell>
|
||||||
|
<mat-cell *matCellDef="let entry">
|
||||||
|
<mat-checkbox disabled [checked]="entry.newEntry.is_active"> </mat-checkbox>
|
||||||
|
</mat-cell>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="is_present">
|
||||||
|
<mat-header-cell *matHeaderCellDef translate>Is Present</mat-header-cell>
|
||||||
|
<mat-cell *matCellDef="let entry">
|
||||||
|
<mat-checkbox disabled [checked]="entry.newEntry.is_present"> </mat-checkbox>
|
||||||
|
</mat-cell>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="is_committee">
|
||||||
|
<mat-header-cell *matHeaderCellDef translate>Is Committee</mat-header-cell>
|
||||||
|
<mat-cell *matCellDef="let entry">
|
||||||
|
<mat-checkbox disabled [checked]="entry.newEntry.is_committee"> </mat-checkbox>
|
||||||
|
</mat-cell>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="default_password">
|
||||||
|
<mat-header-cell *matHeaderCellDef translate>Initial password</mat-header-cell>
|
||||||
|
<mat-cell *matCellDef="let entry"> {{ entry.newEntry.default_password }} </mat-cell>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="email">
|
||||||
|
<mat-header-cell *matHeaderCellDef translate>Email</mat-header-cell>
|
||||||
|
<mat-cell *matCellDef="let entry"> {{ entry.newEntry.email }} </mat-cell>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<mat-header-row *matHeaderRowDef="getColumnDefinition()"></mat-header-row>
|
||||||
|
<mat-row [ngClass]="getStateClass(row)" *matRowDef="let row; columns: getColumnDefinition()"> </mat-row>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</mat-card>
|
@ -0,0 +1,26 @@
|
|||||||
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { UserImportListComponent } from './user-import-list.component';
|
||||||
|
import { E2EImportsModule } from 'e2e-imports.module';
|
||||||
|
|
||||||
|
describe('UserImportListComponent', () => {
|
||||||
|
let component: UserImportListComponent;
|
||||||
|
let fixture: ComponentFixture<UserImportListComponent>;
|
||||||
|
|
||||||
|
beforeEach(async(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
declarations: [UserImportListComponent],
|
||||||
|
imports: [E2EImportsModule]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(UserImportListComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,103 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { MatSnackBar } from '@angular/material';
|
||||||
|
import { Title } from '@angular/platform-browser';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
|
||||||
|
import { BaseImportListComponent } from 'app/site/base/base-import-list';
|
||||||
|
import { FileExportService } from 'app/core/services/file-export.service';
|
||||||
|
import { FormBuilder, FormGroup } from '@angular/forms';
|
||||||
|
import { NewEntry } from 'app/core/services/base-import.service';
|
||||||
|
import { UserImportService } from '../../services/user-import.service';
|
||||||
|
import { ViewUser } from '../../models/view-user';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component for the user import list view.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'os-user-import-list',
|
||||||
|
templateUrl: './user-import-list.component.html'
|
||||||
|
})
|
||||||
|
export class UserImportListComponent extends BaseImportListComponent<ViewUser> {
|
||||||
|
public textAreaForm: FormGroup;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor for list view bases
|
||||||
|
*
|
||||||
|
* @param titleService the title serivce
|
||||||
|
* @param matSnackBar snackbar for displaying errors
|
||||||
|
* @param formBuilder: FormBuilder for the textArea
|
||||||
|
* @param translate the translate service
|
||||||
|
* @param exporter: csv export service for dummy dat
|
||||||
|
* @param importer: The motion csv import service
|
||||||
|
*/
|
||||||
|
public constructor(
|
||||||
|
titleService: Title,
|
||||||
|
matSnackBar: MatSnackBar,
|
||||||
|
formBuilder: FormBuilder,
|
||||||
|
public translate: TranslateService,
|
||||||
|
private exporter: FileExportService,
|
||||||
|
importer: UserImportService
|
||||||
|
) {
|
||||||
|
super(importer, titleService, translate, matSnackBar);
|
||||||
|
this.textAreaForm = formBuilder.group({ inputtext: [''] });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers an example csv download
|
||||||
|
*/
|
||||||
|
public downloadCsvExample(): void {
|
||||||
|
const headerRow = [
|
||||||
|
'Title',
|
||||||
|
'Given name',
|
||||||
|
'Surname',
|
||||||
|
'Structure level',
|
||||||
|
'Participant number',
|
||||||
|
'Groups',
|
||||||
|
'Comment',
|
||||||
|
'Is active',
|
||||||
|
'Is present',
|
||||||
|
'Is a committee',
|
||||||
|
'Initial password',
|
||||||
|
'Email'
|
||||||
|
]
|
||||||
|
.map(item => this.translate.instant(item))
|
||||||
|
.join(',');
|
||||||
|
const rows = [
|
||||||
|
headerRow,
|
||||||
|
'Dr.,Max,Mustermann,"Berlin",1234567890,"Delegates, Staff",xyz,1,1,,initialPassword,',
|
||||||
|
',John,Doe,Washington,75/99/8-2,Committees,"This is a comment, without doubt",1,1,,,john.doe@email.com',
|
||||||
|
',Fred,Bloggs,London,,,,,,,,',
|
||||||
|
',,Executive Board,,,,,,,1,,'
|
||||||
|
];
|
||||||
|
this.exporter.saveFile(rows.join('\n'), this.translate.instant('User example') + '.csv');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shorthand for getVerboseError on name fields checking for duplicates and invalid fields
|
||||||
|
*
|
||||||
|
* @param row
|
||||||
|
* @returns an error string similar to getVerboseError
|
||||||
|
*/
|
||||||
|
public nameErrors(row: NewEntry<ViewUser>): string {
|
||||||
|
for (const name of ['NoName', 'Duplicates', 'DuplicateImport']) {
|
||||||
|
if (this.importer.hasError(row, name)) {
|
||||||
|
return this.importer.verbose(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends the data in the text field input area to the importer
|
||||||
|
*/
|
||||||
|
public parseTextArea(): void {
|
||||||
|
(this.importer as UserImportService).parseTextArea(this.textAreaForm.get('inputtext').value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers a change in the tab group: Clearing the preview selection
|
||||||
|
*/
|
||||||
|
public onTabChange(): void {
|
||||||
|
this.importer.clearPreview();
|
||||||
|
}
|
||||||
|
}
|
@ -90,6 +90,12 @@
|
|||||||
<mat-icon>archive</mat-icon>
|
<mat-icon>archive</mat-icon>
|
||||||
<span translate>Export as CSV</span>
|
<span translate>Export as CSV</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button mat-menu-item *osPerms="'users.can_manage'" routerLink="import">
|
||||||
|
<mat-icon>save_alt</mat-icon>
|
||||||
|
<span translate>Import</span><span> ...</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="isMultiSelect">
|
<div *ngIf="isMultiSelect">
|
||||||
<button mat-menu-item (click)="selectAll()">
|
<button mat-menu-item (click)="selectAll()">
|
||||||
|
@ -79,15 +79,6 @@ export class UserListComponent extends ListViewBaseComponent<ViewUser> implement
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigate to import page or do it inline
|
|
||||||
*
|
|
||||||
* TODO: implement importing of users
|
|
||||||
*/
|
|
||||||
public import(): void {
|
|
||||||
console.log('click on Import');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the click on a user row if not in multiSelect modus
|
* Handles the click on a user row if not in multiSelect modus
|
||||||
* @param row selected row
|
* @param row selected row
|
||||||
|
73
client/src/app/site/users/models/view-csv-create-user.ts
Normal file
73
client/src/app/site/users/models/view-csv-create-user.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { ViewUser } from './view-user';
|
||||||
|
import { User } from 'app/shared/models/users/user';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for correlating between strings representing BaseModels and existing
|
||||||
|
* BaseModels.
|
||||||
|
*/
|
||||||
|
export interface CsvMapping {
|
||||||
|
name: string;
|
||||||
|
id?: number;
|
||||||
|
multiId?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* View class for a new User during text imports. Offers a mapping and matching
|
||||||
|
* to secondary import data (groups)
|
||||||
|
*
|
||||||
|
* @ignore
|
||||||
|
*/
|
||||||
|
export class ViewCsvCreateUser extends ViewUser {
|
||||||
|
/**
|
||||||
|
* Mapping for a new/existing groups.
|
||||||
|
*/
|
||||||
|
public csvGroups: CsvMapping[] = [];
|
||||||
|
|
||||||
|
public title: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getter if the minimum requrements for a user are met: A name
|
||||||
|
*
|
||||||
|
* @returns false if the user has neither first nor last name
|
||||||
|
*/
|
||||||
|
public get isValid(): boolean {
|
||||||
|
if (this.user && (this.first_name || this.last_name)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public constructor(user?: User) {
|
||||||
|
super(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* takes a list of solved group maps to update. Returns the amount of
|
||||||
|
* entries that remain unmatched
|
||||||
|
*
|
||||||
|
* @param groups
|
||||||
|
*/
|
||||||
|
public solveGroups(groups: CsvMapping[]): number {
|
||||||
|
let open = 0;
|
||||||
|
const ids: number[] = [];
|
||||||
|
this.csvGroups.forEach(group => {
|
||||||
|
if (group.id) {
|
||||||
|
ids.push(group.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!groups.length) {
|
||||||
|
open += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mapped = groups.find(newGroup => newGroup.name === group.name);
|
||||||
|
if (mapped) {
|
||||||
|
group.id = mapped.id;
|
||||||
|
ids.push(mapped.id);
|
||||||
|
} else {
|
||||||
|
open += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.user.groups_id = ids;
|
||||||
|
return open;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,17 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { E2EImportsModule } from 'e2e-imports.module';
|
||||||
|
import { UserImportService } from './user-import.service';
|
||||||
|
|
||||||
|
describe('UserImportService', () => {
|
||||||
|
beforeEach(() =>
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [E2EImportsModule]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
const service: UserImportService = TestBed.get(UserImportService);
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
271
client/src/app/site/users/services/user-import.service.ts
Normal file
271
client/src/app/site/users/services/user-import.service.ts
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { MatSnackBar } from '@angular/material';
|
||||||
|
import { Papa } from 'ngx-papaparse';
|
||||||
|
|
||||||
|
import { BaseImportService, NewEntry } from 'app/core/services/base-import.service';
|
||||||
|
import { Group } from 'app/shared/models/users/group';
|
||||||
|
import { GroupRepositoryService } from './group-repository.service';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
import { User } from 'app/shared/models/users/user';
|
||||||
|
import { UserRepositoryService } from './user-repository.service';
|
||||||
|
import { ViewCsvCreateUser, CsvMapping } from '../models/view-csv-create-user';
|
||||||
|
import { ViewUser } from '../models/view-user';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class UserImportService extends BaseImportService<ViewUser> {
|
||||||
|
/**
|
||||||
|
* Helper for mapping the expected header in a typesafe way. Values and order
|
||||||
|
* will be passed to {@link expectedHeader}
|
||||||
|
*/
|
||||||
|
public headerMap: (keyof ViewCsvCreateUser)[] = [
|
||||||
|
'title',
|
||||||
|
'first_name',
|
||||||
|
'last_name',
|
||||||
|
'structure_level',
|
||||||
|
'participant_number',
|
||||||
|
'groups_id',
|
||||||
|
'comment',
|
||||||
|
'is_active',
|
||||||
|
'is_present',
|
||||||
|
'is_committee',
|
||||||
|
'default_password',
|
||||||
|
'email'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The minimimal number of header entries needed to successfully create an entry
|
||||||
|
*/
|
||||||
|
public requiredHeaderLength = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of possible errors and their verbose explanation
|
||||||
|
*/
|
||||||
|
public errorList = {
|
||||||
|
Group: 'Group cannot be resolved',
|
||||||
|
Duplicates: 'This user already exists',
|
||||||
|
NoName: 'Entry has no valid name',
|
||||||
|
DuplicateImport: 'Entry cannot be imported twice. This line will be ommitted',
|
||||||
|
ParsingErrors: 'Some csv values could not be read correctly.'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage for tracking new groups to be created prior to importing users
|
||||||
|
*/
|
||||||
|
public newGroups: CsvMapping[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor. Calls parent and sets the expected header
|
||||||
|
*
|
||||||
|
* @param repo The User repository
|
||||||
|
* @param groupRepo the Group repository
|
||||||
|
* @param translate TranslationService
|
||||||
|
* @param papa csvParser
|
||||||
|
* @param matSnackbar MatSnackBar for displaying error messages
|
||||||
|
*/
|
||||||
|
public constructor(
|
||||||
|
private repo: UserRepositoryService,
|
||||||
|
private groupRepo: GroupRepositoryService,
|
||||||
|
translate: TranslateService,
|
||||||
|
papa: Papa,
|
||||||
|
matSnackbar: MatSnackBar
|
||||||
|
) {
|
||||||
|
super(translate, papa, matSnackbar);
|
||||||
|
this.expectedHeader = this.headerMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all temporary data specific to this importer
|
||||||
|
*/
|
||||||
|
public clearData(): void {
|
||||||
|
this.newGroups = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a string representing an entry, extracting secondary data, appending
|
||||||
|
* the array of secondary imports as needed
|
||||||
|
*
|
||||||
|
* @param line
|
||||||
|
* @returns a new entry representing an User
|
||||||
|
*/
|
||||||
|
public mapData(line: string): NewEntry<ViewUser> {
|
||||||
|
const newViewUser = new ViewCsvCreateUser(new User());
|
||||||
|
const headerLength = Math.min(this.expectedHeader.length, line.length);
|
||||||
|
let hasErrors = false;
|
||||||
|
for (let idx = 0; idx < headerLength; idx++) {
|
||||||
|
switch (this.expectedHeader[idx]) {
|
||||||
|
case 'groups_id':
|
||||||
|
newViewUser.csvGroups = this.getGroups(line[idx]);
|
||||||
|
break;
|
||||||
|
case 'is_active':
|
||||||
|
case 'is_committee':
|
||||||
|
case 'is_present':
|
||||||
|
try {
|
||||||
|
newViewUser.user[this.expectedHeader[idx]] = this.toBoolean(line[idx]);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof TypeError) {
|
||||||
|
console.log(e);
|
||||||
|
hasErrors = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'participant_number':
|
||||||
|
newViewUser.user.number = line[idx];
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
newViewUser.user[this.expectedHeader[idx]] = line[idx];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const newEntry = this.userToEntry(newViewUser);
|
||||||
|
if (hasErrors) {
|
||||||
|
this.setError(newEntry, 'ParsingErrors');
|
||||||
|
}
|
||||||
|
return newEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executing the import. Creates all secondary data, maps the newly created
|
||||||
|
* secondary data to the new entries, then creates all entries without errors
|
||||||
|
* by submitting them to the server. The entries will receive the status
|
||||||
|
* 'done' on success.
|
||||||
|
*/
|
||||||
|
public async doImport(): Promise<void> {
|
||||||
|
this.newGroups = await this.createNewGroups();
|
||||||
|
for (const entry of this.entries) {
|
||||||
|
if (entry.status !== 'new') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const openBlocks = (entry.newEntry as ViewCsvCreateUser).solveGroups(this.newGroups);
|
||||||
|
if (openBlocks) {
|
||||||
|
this.setError(entry, 'Group');
|
||||||
|
this.updatePreview();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await this.repo.create(entry.newEntry.user);
|
||||||
|
entry.status = 'done';
|
||||||
|
}
|
||||||
|
this.updatePreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* extracts the group(s) from a csv column and tries to match them against existing groups,
|
||||||
|
* appending to {@link newGroups} if needed.
|
||||||
|
* Also checks for groups matching the translation between english and the language currently set
|
||||||
|
*
|
||||||
|
* @param groupString string from an entry line including one or more comma separated groups
|
||||||
|
* @returns a mapping with (if existing) ids to the group names
|
||||||
|
*/
|
||||||
|
private getGroups(groupString: string): CsvMapping[] {
|
||||||
|
const result: CsvMapping[] = [];
|
||||||
|
if (!groupString) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
groupString.trim();
|
||||||
|
const groupArray = groupString.split(',');
|
||||||
|
for (const item of groupArray) {
|
||||||
|
const newGroup = item.trim();
|
||||||
|
let existingGroup = this.groupRepo.getViewModelList().filter(grp => grp.name === newGroup);
|
||||||
|
if (!existingGroup.length) {
|
||||||
|
existingGroup = this.groupRepo
|
||||||
|
.getViewModelList()
|
||||||
|
.filter(grp => this.translate.instant(grp.name) === newGroup);
|
||||||
|
}
|
||||||
|
if (!existingGroup.length) {
|
||||||
|
if (!this.newGroups.find(listedGrp => listedGrp.name === newGroup)) {
|
||||||
|
this.newGroups.push({ name: newGroup });
|
||||||
|
}
|
||||||
|
result.push({ name: newGroup });
|
||||||
|
} else if (existingGroup.length === 1) {
|
||||||
|
result.push({
|
||||||
|
name: existingGroup[0].name,
|
||||||
|
id: existingGroup[0].id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the creation of new groups collected in {@link newGroups}.
|
||||||
|
*
|
||||||
|
* @returns The group mapping with (on success) new ids
|
||||||
|
*/
|
||||||
|
private async createNewGroups(): Promise<CsvMapping[]> {
|
||||||
|
const promises: Promise<CsvMapping>[] = [];
|
||||||
|
for (const group of this.newGroups) {
|
||||||
|
promises.push(
|
||||||
|
this.groupRepo.create(new Group({ name: group.name })).then(identifiable => {
|
||||||
|
return { name: group.name, id: identifiable.id };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return await Promise.all(promises);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* translates a string into a boolean
|
||||||
|
*
|
||||||
|
* @param data
|
||||||
|
* @returns a boolean from the string
|
||||||
|
*/
|
||||||
|
private toBoolean(data: string): Boolean {
|
||||||
|
if (!data || data === '0' || data === 'false') {
|
||||||
|
return false;
|
||||||
|
} else if (data === '1' || data === 'true') {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
throw new TypeError('Value cannot be translated into boolean: ' + data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* parses the data given by the textArea. Expects user names separated by lines.
|
||||||
|
* Comma separated values will be read as Surname(s), given name(s) (lastCommaFirst)
|
||||||
|
*
|
||||||
|
* @param data a string as produced by textArea input
|
||||||
|
*/
|
||||||
|
public parseTextArea(data: string): void {
|
||||||
|
const newEntries: NewEntry<ViewUser>[] = [];
|
||||||
|
this.clearData();
|
||||||
|
this.clearPreview();
|
||||||
|
const lines = data.split('\n');
|
||||||
|
lines.forEach(line => {
|
||||||
|
if (!line.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nameSchema = line.includes(',') ? 'lastCommaFirst' : 'firstSpaceLast';
|
||||||
|
const newUser = new ViewCsvCreateUser(this.repo.parseUserString(line, nameSchema));
|
||||||
|
const newEntry = this.userToEntry(newUser);
|
||||||
|
newEntries.push(newEntry);
|
||||||
|
});
|
||||||
|
this.setParsedEntries(newEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks a newly created ViewCsvCreateuser for validity and duplicates,
|
||||||
|
*
|
||||||
|
* @param newUser
|
||||||
|
* @returns a NewEntry with duplicate/error information
|
||||||
|
*/
|
||||||
|
private userToEntry(newUser: ViewCsvCreateUser): NewEntry<ViewUser> {
|
||||||
|
const newEntry: NewEntry<ViewUser> = {
|
||||||
|
newEntry: newUser,
|
||||||
|
duplicates: [],
|
||||||
|
status: 'new',
|
||||||
|
errors: []
|
||||||
|
};
|
||||||
|
if (newUser.isValid) {
|
||||||
|
const updateModels = this.repo.getUserDuplicates(newUser);
|
||||||
|
if (updateModels.length) {
|
||||||
|
newEntry.duplicates = updateModels;
|
||||||
|
this.setError(newEntry, 'Duplicates');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.setError(newEntry, 'NoName');
|
||||||
|
}
|
||||||
|
return newEntry;
|
||||||
|
}
|
||||||
|
}
|
@ -13,6 +13,12 @@ import { HttpService } from 'app/core/services/http.service';
|
|||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import { environment } from '../../../../environments/environment';
|
import { environment } from '../../../../environments/environment';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* type for determining the user name from a string during import.
|
||||||
|
* See {@link parseUserString} for implementations
|
||||||
|
*/
|
||||||
|
type StringNamingSchema = 'lastCommaFirst' | 'firstSpaceLast';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Repository service for users
|
* Repository service for users
|
||||||
*
|
*
|
||||||
@ -222,12 +228,43 @@ export class UserRepositoryService extends BaseRepository<ViewUser, User> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new User from a string
|
* Creates a new User from a string
|
||||||
|
*
|
||||||
* @param user: String to create the user from
|
* @param user: String to create the user from
|
||||||
* TODO: return 'user' + new id
|
* @returns Promise with a created user id and the raw name used as input
|
||||||
*/
|
*/
|
||||||
public async createFromString(user: string): Promise<{ id: number; name: string }> {
|
public async createFromString(user: string): Promise<{ id: number; name: string }> {
|
||||||
const splitUser = user.split(' ');
|
const newUser = this.parseUserString(user);
|
||||||
|
const createdUser = await this.create(newUser);
|
||||||
|
return { id: createdUser.id, name: user };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tries to convert a user string into an user. If it is two words, expect
|
||||||
|
* a first and a last name, if one word only, expect a first name only.
|
||||||
|
* If more than two words, they will all be put as the first name
|
||||||
|
* TODO: More advanced logic to fit names
|
||||||
|
*
|
||||||
|
* @param inputUser A raw user string
|
||||||
|
* @param schema optional hint on how to handle the strings. TODO: Not fully implemented.
|
||||||
|
* @returns A User object (not uploaded to the server)
|
||||||
|
*/
|
||||||
|
public parseUserString(inputUser: string, schema?: StringNamingSchema): User {
|
||||||
const newUser: Partial<User> = {};
|
const newUser: Partial<User> = {};
|
||||||
|
if (schema === 'lastCommaFirst') {
|
||||||
|
const commaSeparated = inputUser.split(',');
|
||||||
|
switch (commaSeparated.length) {
|
||||||
|
case 1:
|
||||||
|
newUser.first_name = commaSeparated[0];
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
newUser.last_name = commaSeparated[0];
|
||||||
|
newUser.first_name = commaSeparated[1];
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
newUser.first_name = inputUser;
|
||||||
|
}
|
||||||
|
} else if (!schema || schema === 'firstSpaceLast') {
|
||||||
|
const splitUser = inputUser.split(' ');
|
||||||
switch (splitUser.length) {
|
switch (splitUser.length) {
|
||||||
case 1:
|
case 1:
|
||||||
newUser.first_name = splitUser[0];
|
newUser.first_name = splitUser[0];
|
||||||
@ -237,9 +274,17 @@ export class UserRepositoryService extends BaseRepository<ViewUser, User> {
|
|||||||
newUser.last_name = splitUser[1];
|
newUser.last_name = splitUser[1];
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
newUser.first_name = user;
|
newUser.first_name = inputUser;
|
||||||
}
|
}
|
||||||
const createdUser = await this.create(newUser);
|
}
|
||||||
return { id: createdUser.id, name: user };
|
return new User(newUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all duplicates of an user (currently: full name matches)
|
||||||
|
* @param user
|
||||||
|
*/
|
||||||
|
public getUserDuplicates(user: ViewUser): ViewUser[] {
|
||||||
|
return this.getViewModelList().filter(existingUser => existingUser.full_name === user.full_name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
import { Routes, RouterModule } from '@angular/router';
|
import { Routes, RouterModule } from '@angular/router';
|
||||||
import { UserListComponent } from './components/user-list/user-list.component';
|
|
||||||
import { UserDetailComponent } from './components/user-detail/user-detail.component';
|
|
||||||
import { GroupListComponent } from './components/group-list/group-list.component';
|
import { GroupListComponent } from './components/group-list/group-list.component';
|
||||||
import { PasswordComponent } from './components/password/password.component';
|
import { PasswordComponent } from './components/password/password.component';
|
||||||
|
import { UserDetailComponent } from './components/user-detail/user-detail.component';
|
||||||
|
import { UserImportListComponent } from './components/user-import/user-import-list.component';
|
||||||
|
import { UserListComponent } from './components/user-list/user-list.component';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{
|
{
|
||||||
@ -22,6 +24,10 @@ const routes: Routes = [
|
|||||||
path: 'new',
|
path: 'new',
|
||||||
component: UserDetailComponent
|
component: UserDetailComponent
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'import',
|
||||||
|
component: UserImportListComponent
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'groups',
|
path: 'groups',
|
||||||
component: GroupListComponent
|
component: GroupListComponent
|
||||||
|
@ -1,15 +1,22 @@
|
|||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
import { UsersRoutingModule } from './users-routing.module';
|
|
||||||
import { SharedModule } from '../../shared/shared.module';
|
|
||||||
import { UserListComponent } from './components/user-list/user-list.component';
|
|
||||||
import { UserDetailComponent } from './components/user-detail/user-detail.component';
|
|
||||||
import { GroupListComponent } from './components/group-list/group-list.component';
|
import { GroupListComponent } from './components/group-list/group-list.component';
|
||||||
import { PasswordComponent } from './components/password/password.component';
|
import { PasswordComponent } from './components/password/password.component';
|
||||||
|
import { SharedModule } from '../../shared/shared.module';
|
||||||
|
import { UserDetailComponent } from './components/user-detail/user-detail.component';
|
||||||
|
import { UserImportListComponent } from './components/user-import/user-import-list.component';
|
||||||
|
import { UserListComponent } from './components/user-list/user-list.component';
|
||||||
|
import { UsersRoutingModule } from './users-routing.module';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [CommonModule, UsersRoutingModule, SharedModule],
|
imports: [CommonModule, UsersRoutingModule, SharedModule],
|
||||||
declarations: [UserListComponent, UserDetailComponent, GroupListComponent, PasswordComponent]
|
declarations: [
|
||||||
|
UserListComponent,
|
||||||
|
UserDetailComponent,
|
||||||
|
GroupListComponent,
|
||||||
|
PasswordComponent,
|
||||||
|
UserImportListComponent
|
||||||
|
]
|
||||||
})
|
})
|
||||||
export class UsersModule {}
|
export class UsersModule {}
|
||||||
|
@ -16,7 +16,6 @@
|
|||||||
|
|
||||||
@import '~angular-tree-component/dist/angular-tree-component.css';
|
@import '~angular-tree-component/dist/angular-tree-component.css';
|
||||||
|
|
||||||
|
|
||||||
* {
|
* {
|
||||||
font-family: Fira Sans, Roboto, Arial, Helvetica, sans-serif;
|
font-family: Fira Sans, Roboto, Arial, Helvetica, sans-serif;
|
||||||
}
|
}
|
||||||
@ -72,14 +71,15 @@ img {
|
|||||||
|
|
||||||
a {
|
a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: #039BE5; /*TODO: move to theme*/
|
color: #039be5; /*TODO: move to theme*/
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
strong, b {
|
strong,
|
||||||
|
b {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -113,8 +113,9 @@ strong, b {
|
|||||||
color: mat-color($openslides-accent);
|
color: mat-color($openslides-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.green-text { // TODO better name/theming
|
.green-text {
|
||||||
color: #5A5;
|
// TODO better name/theming
|
||||||
|
color: #5a5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-text-distance {
|
.icon-text-distance {
|
||||||
@ -152,7 +153,6 @@ mat-card {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Shared table definitions
|
// Shared table definitions
|
||||||
%os-table {
|
%os-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -175,8 +175,14 @@ mat-card {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
0% {width:0%; margin-left:0;}
|
0% {
|
||||||
100% {width:100%;margin-left:-100%;}
|
width: 0%;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
width: 100%;
|
||||||
|
margin-left: -100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//custom table header for search button, filtering and more. Used in ListViews
|
//custom table header for search button, filtering and more. Used in ListViews
|
||||||
@ -198,7 +204,7 @@ mat-card {
|
|||||||
position: relative;
|
position: relative;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
background-color: #EEE;
|
background-color: #eee;
|
||||||
padding-right: 5px;
|
padding-right: 5px;
|
||||||
margin-right: 0px;
|
margin-right: 0px;
|
||||||
}
|
}
|
||||||
@ -338,7 +344,6 @@ button.mat-menu-item.selected {
|
|||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** helper classes for margin/padding */
|
/** helper classes for margin/padding */
|
||||||
.spacer-top-10 {
|
.spacer-top-10 {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
@ -381,13 +386,11 @@ button.mat-menu-item.selected {
|
|||||||
padding-right: 20px;
|
padding-right: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** more helper classes **/
|
/** more helper classes **/
|
||||||
.center {
|
.center {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** Colors **/
|
/** Colors **/
|
||||||
.lightblue {
|
.lightblue {
|
||||||
background-color: rgb(33, 150, 243) !important;
|
background-color: rgb(33, 150, 243) !important;
|
||||||
@ -427,8 +430,6 @@ button.mat-menu-item.selected {
|
|||||||
color: rgba(0, 0, 0, 0.87) !important;
|
color: rgba(0, 0, 0, 0.87) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* TODO: move to site.component.scss-theme.scss (does not work currently) */
|
/* TODO: move to site.component.scss-theme.scss (does not work currently) */
|
||||||
|
|
||||||
/* make the .user-menu expansion panel look like the nav-toolbar above */
|
/* make the .user-menu expansion panel look like the nav-toolbar above */
|
||||||
@ -452,3 +453,69 @@ button.mat-menu-item.selected {
|
|||||||
.mat-drawer-inner-container::-webkit-scrollbar {
|
.mat-drawer-inner-container::-webkit-scrollbar {
|
||||||
display: none !important; /* hide scrollbars in webkit browsers */
|
display: none !important; /* hide scrollbars in webkit browsers */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.import-table {
|
||||||
|
.table-container {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: scroll;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
overflow: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-header-cell {
|
||||||
|
min-width: 100px;
|
||||||
|
flex: 2;
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
.mat-cell {
|
||||||
|
min-width: 100px;
|
||||||
|
flex: 2;
|
||||||
|
padding-top: 2px;
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
.selection {
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-done {
|
||||||
|
background-color: #cfc;
|
||||||
|
}
|
||||||
|
.import-error {
|
||||||
|
background-color: #fcc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
padding-left: 1em;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.wrapper {
|
||||||
|
display: flex;
|
||||||
|
vertical-align: bottom;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.summary {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newBadge {
|
||||||
|
margin-left: -3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.first-column {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0px;
|
||||||
|
}
|
||||||
|
.filter-imports {
|
||||||
|
max-width: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user