Merge pull request #4036 from FinnStutzenstein/calllist_export
Call list csv export
This commit is contained in:
commit
86e858c800
@ -5,6 +5,51 @@ import { BaseViewModel } from '../../site/base/base-view-model';
|
||||
import { FileExportService } from './file-export.service';
|
||||
import { ConfigService } from './config.service';
|
||||
|
||||
/**
|
||||
* Defines a csv column with a property of the model and an optional label. If this is not given, the
|
||||
* translated and capitalized property name is used.
|
||||
*/
|
||||
interface CsvColumnDefinitionProperty<T> {
|
||||
label?: string;
|
||||
property: keyof T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type assertion for CsvColumnDefinitionProperty<T>
|
||||
*
|
||||
* @param obj Any object to test.
|
||||
* @returns true, if the object is a property definition. This is also asserted for TypeScript.
|
||||
*/
|
||||
function isPropertyDefinition<T>(obj: any): obj is CsvColumnDefinitionProperty<T> {
|
||||
return 'property' in obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines a csv column with a map function. Here, the user of this service can define hat should happen with
|
||||
* all the models. This map function is called for every model and the user should return a string that is
|
||||
* put into the csv. Also a column label must be given, that is capitalized and translated.
|
||||
*/
|
||||
interface CsvColumnDefinitionMap<T> {
|
||||
label: string;
|
||||
map: (model: T) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type assertion for CsvColumnDefinitionMap<T>
|
||||
*
|
||||
* @param obj Any object to test.
|
||||
* @returns true, if the objct is a map definition. This is also asserted for TypeScript.
|
||||
*/
|
||||
function isMapDefinition<T>(obj: any): obj is CsvColumnDefinitionMap<T> {
|
||||
return 'map' in obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* The definition of columns in the export. Either use a property for every model or do a custom mapping to
|
||||
* a string to be put into the csv.
|
||||
*/
|
||||
type CsvColumnsDefinition<T> = (CsvColumnDefinitionProperty<T> | CsvColumnDefinitionMap<T>)[];
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
@ -24,7 +69,7 @@ export class CsvExportService {
|
||||
|
||||
/**
|
||||
* Saves an array of model data to a CSV.
|
||||
* @param data Array of Model instances to be saved
|
||||
* @param models Array of model instances to be saved
|
||||
* @param columns Column definitions
|
||||
* @param filename name of the resulting file
|
||||
* @param options optional:
|
||||
@ -32,12 +77,8 @@ export class CsvExportService {
|
||||
* columnseparator defaults to semicolon (other usual separators are ',' '\T' (tab), ' 'whitespace)
|
||||
*/
|
||||
public export<T extends BaseViewModel>(
|
||||
data: T[],
|
||||
columns: {
|
||||
property: keyof T; // name of the property used for export
|
||||
label?: string;
|
||||
assemble?: string; // (if property is further child object, the property of these to be used)
|
||||
}[],
|
||||
models: T[],
|
||||
columns: CsvColumnsDefinition<T>,
|
||||
filename: string,
|
||||
{
|
||||
lineSeparator = '\r\n',
|
||||
@ -47,8 +88,7 @@ export class CsvExportService {
|
||||
columnSeparator?: string;
|
||||
} = {}
|
||||
): void {
|
||||
const allLines = []; // Array of arrays of entries
|
||||
const usedColumns = []; // mapped properties to be included
|
||||
let csvContent = []; // Holds all lines as arrays with each column-value
|
||||
// initial array of usable text separators. The first character not used
|
||||
// in any text data or as column separator will be used as text separator
|
||||
let tsList = ['"', "'", '`', '/', '\\', ';', '.'];
|
||||
@ -61,56 +101,55 @@ export class CsvExportService {
|
||||
tsList = this.checkCsvTextSafety(columnSeparator, tsList);
|
||||
|
||||
// create header data
|
||||
const header = [];
|
||||
columns.forEach(column => {
|
||||
const rawLabel: string = column.label ? column.label : (column.property as string);
|
||||
const colLabel = this.capitalizeTranslate(rawLabel);
|
||||
tsList = this.checkCsvTextSafety(colLabel, tsList);
|
||||
header.push(colLabel);
|
||||
usedColumns.push(column.property);
|
||||
});
|
||||
allLines.push(header);
|
||||
// create lines
|
||||
data.forEach(item => {
|
||||
const line = [];
|
||||
for (let i = 0; i < usedColumns.length; i++) {
|
||||
const property = usedColumns[i];
|
||||
let prop: any = item[property];
|
||||
if (columns[i].assemble) {
|
||||
prop = item[property]
|
||||
.map(subitem => this.translate.instant(subitem[columns[i].assemble]))
|
||||
.join(',');
|
||||
}
|
||||
tsList = this.checkCsvTextSafety(prop, tsList);
|
||||
line.push(prop);
|
||||
const header = columns.map(column => {
|
||||
let label: string;
|
||||
if (isPropertyDefinition(column)) {
|
||||
label = column.label ? column.label : (column.property as string);
|
||||
} else if (isMapDefinition(column)) {
|
||||
label = column.label;
|
||||
}
|
||||
allLines.push(line);
|
||||
label = this.capitalizeTranslate(label);
|
||||
tsList = this.checkCsvTextSafety(label, tsList);
|
||||
return label;
|
||||
});
|
||||
csvContent.push(header);
|
||||
|
||||
// create lines
|
||||
csvContent = csvContent.concat(models.map(model => {
|
||||
return columns.map(column => {
|
||||
let value: string;
|
||||
|
||||
if (isPropertyDefinition(column)) {
|
||||
const property: any = model[column.property];
|
||||
if (typeof property === 'number') {
|
||||
value = property.toString(10);
|
||||
} else if (!property) {
|
||||
value = '';
|
||||
} else if (property === true) {
|
||||
value = '1';
|
||||
} else if (property === false) {
|
||||
value = '0';
|
||||
} else {
|
||||
value = property.toString();
|
||||
}
|
||||
} else if (isMapDefinition(column)) {
|
||||
value = column.map(model);
|
||||
}
|
||||
tsList = this.checkCsvTextSafety(value, tsList);
|
||||
|
||||
return value;
|
||||
});
|
||||
}));
|
||||
|
||||
// assemble lines, putting text separator in place
|
||||
if (!tsList.length) {
|
||||
throw new Error('no usable text separator left for valid csv text');
|
||||
}
|
||||
|
||||
const allLinesAssembled = [];
|
||||
allLines.forEach(line => {
|
||||
const assembledLine = [];
|
||||
line.forEach(item => {
|
||||
if (typeof item === 'number') {
|
||||
assembledLine.push(item.toString(10));
|
||||
} else if (item === null || item === undefined || item === '') {
|
||||
assembledLine.push('');
|
||||
} else if (item === true) {
|
||||
assembledLine.push('1');
|
||||
} else if (item === false) {
|
||||
assembledLine.push('0');
|
||||
} else {
|
||||
assembledLine.push(tsList[0] + item + tsList[0]);
|
||||
}
|
||||
});
|
||||
allLinesAssembled.push(assembledLine.join(columnSeparator));
|
||||
});
|
||||
this.exporter.saveFile(allLinesAssembled.join(lineSeparator), filename);
|
||||
const csvContentAsString: string = csvContent.map(line => {
|
||||
return line.map(entry => tsList[0] + entry + tsList[0]).join(columnSeparator);
|
||||
}).join(lineSeparator);
|
||||
this.exporter.saveFile(csvContentAsString, filename);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -122,13 +161,12 @@ export class CsvExportService {
|
||||
* @param tsList The list of special characters to check.
|
||||
* @returns the cleand CSV String list
|
||||
*/
|
||||
public checkCsvTextSafety(input: any, tsList: string[]): string[] {
|
||||
public checkCsvTextSafety(input: string, tsList: string[]): string[] {
|
||||
if (input === null || input === undefined) {
|
||||
return tsList;
|
||||
}
|
||||
|
||||
const inputAsString = String(input);
|
||||
return tsList.filter(char => inputAsString.indexOf(char) < 0);
|
||||
return tsList.filter(char => input.indexOf(char) < 0);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -3,6 +3,13 @@
|
||||
<div class="title-slot">
|
||||
<h2 translate>Call list</h2>
|
||||
</div>
|
||||
|
||||
<!-- Export -->
|
||||
<div class="menu-slot">
|
||||
<button mat-icon-button (click)="csvExportCallList()">
|
||||
<mat-icon>archive</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</os-head-bar>
|
||||
|
||||
<mat-card>
|
||||
|
@ -10,6 +10,7 @@ import { MotionRepositoryService } from '../../services/motion-repository.servic
|
||||
import { ViewMotion } from '../../models/view-motion';
|
||||
import { SortingListComponent } from '../../../../shared/components/sorting-list/sorting-list.component';
|
||||
import { OSTreeSortEvent } from 'app/shared/components/sorting-tree/sorting-tree.component';
|
||||
import { MotionCsvExportService } from '../../services/motion-csv-export.service';
|
||||
|
||||
/**
|
||||
* Sort view for the call list.
|
||||
@ -24,6 +25,11 @@ export class CallListComponent extends BaseViewComponent {
|
||||
*/
|
||||
public motionsObservable: Observable<ViewMotion[]>;
|
||||
|
||||
/**
|
||||
* Holds all motions for the export.
|
||||
*/
|
||||
private motions: ViewMotion[] = [];
|
||||
|
||||
/**
|
||||
* Emits true for expand and false for collaps. Informs the sorter component about this actions.
|
||||
*/
|
||||
@ -46,11 +52,16 @@ export class CallListComponent extends BaseViewComponent {
|
||||
title: Title,
|
||||
translate: TranslateService,
|
||||
matSnackBar: MatSnackBar,
|
||||
private motionRepo: MotionRepositoryService
|
||||
private motionRepo: MotionRepositoryService,
|
||||
private motionCsvExport: MotionCsvExportService
|
||||
) {
|
||||
super(title, translate, matSnackBar);
|
||||
|
||||
this.motionsObservable = this.motionRepo.getViewModelListObservable();
|
||||
this.motionsObservable.subscribe(motions => {
|
||||
// Sort motions and make a copy, so it will stay sorted.
|
||||
this.motions = motions.map(x => x).sort((a, b) => a.callListWeight - b.callListWeight);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -72,4 +83,11 @@ export class CallListComponent extends BaseViewComponent {
|
||||
public expandCollapseAll(expand: boolean): void {
|
||||
this.expandCollapse.emit(expand);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the full call list as csv.
|
||||
*/
|
||||
public csvExportCallList(): void {
|
||||
this.motionCsvExport.exportCallList(this.motions);
|
||||
}
|
||||
}
|
||||
|
@ -114,12 +114,14 @@
|
||||
<mat-icon>device_hub</mat-icon>
|
||||
<span translate>Categories</span>
|
||||
</button>
|
||||
|
||||
<button mat-menu-item routerLink="comment-section">
|
||||
<mat-icon>speaker_notes</mat-icon>
|
||||
<span translate>Comment sections</span>
|
||||
</button>
|
||||
|
||||
<button mat-menu-item *osPerms="'motions.can_manage'" routerLink="call-list">
|
||||
<mat-icon>sort</mat-icon>
|
||||
<span translate>Call list</span>
|
||||
</button>
|
||||
<button mat-menu-item routerLink="statute-paragraphs" *ngIf="statutesEnabled">
|
||||
<mat-icon>account_balance</mat-icon>
|
||||
<span translate>Statute paragraphs</span>
|
||||
|
@ -9,9 +9,9 @@ import { WorkflowState } from '../../../../shared/models/motions/workflow-state'
|
||||
import { ListViewBaseComponent } from '../../../base/list-view-base';
|
||||
import { MatSnackBar } from '@angular/material';
|
||||
import { ConfigService } from '../../../../core/services/config.service';
|
||||
import { CsvExportService } from 'app/core/services/csv-export.service';
|
||||
import { Category } from '../../../../shared/models/motions/category';
|
||||
import { PromptService } from '../../../../core/services/prompt.service';
|
||||
import { MotionCsvExportService } from '../../services/motion-csv-export.service';
|
||||
|
||||
/**
|
||||
* Component that displays all the motions in a Table using DataSource.
|
||||
@ -62,8 +62,8 @@ export class MotionListComponent extends ListViewBaseComponent<ViewMotion> imple
|
||||
private route: ActivatedRoute,
|
||||
private configService: ConfigService,
|
||||
private repo: MotionRepositoryService,
|
||||
private csvExport: CsvExportService,
|
||||
private promptService: PromptService
|
||||
private promptService: PromptService,
|
||||
private motionCsvExport: MotionCsvExportService
|
||||
) {
|
||||
super(titleService, translate, matSnackBar);
|
||||
|
||||
@ -160,19 +160,7 @@ export class MotionListComponent extends ListViewBaseComponent<ViewMotion> imple
|
||||
* Export all motions as CSV
|
||||
*/
|
||||
public csvExportMotionList(): void {
|
||||
this.csvExport.export(
|
||||
this.dataSource.data,
|
||||
[
|
||||
{ property: 'identifier' },
|
||||
{ property: 'title' },
|
||||
{ property: 'text' },
|
||||
{ property: 'reason' },
|
||||
{ property: 'submitters' },
|
||||
{ property: 'category' },
|
||||
{ property: 'origin' }
|
||||
],
|
||||
this.translate.instant('Motions') + '.csv'
|
||||
);
|
||||
this.motionCsvExport.exportMotionList(this.dataSource.data);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -8,6 +8,7 @@ import { BaseViewModel } from '../../base/base-view-model';
|
||||
import { ViewMotionCommentSection } from './view-motion-comment-section';
|
||||
import { MotionComment } from '../../../shared/models/motions/motion-comment';
|
||||
import { Item } from 'app/shared/models/agenda/item';
|
||||
import { MotionBlock } from 'app/shared/models/motions/motion-block';
|
||||
|
||||
export enum LineNumberingMode {
|
||||
None,
|
||||
@ -37,6 +38,7 @@ export class ViewMotion extends BaseViewModel {
|
||||
private _workflow: Workflow;
|
||||
private _state: WorkflowState;
|
||||
private _item: Item;
|
||||
private _block: MotionBlock;
|
||||
|
||||
/**
|
||||
* Indicates the LineNumberingMode Mode.
|
||||
@ -84,6 +86,13 @@ export class ViewMotion extends BaseViewModel {
|
||||
return this.motion ? this.motion.title : null;
|
||||
}
|
||||
|
||||
public get identifierOrTitle(): string {
|
||||
if (!this.motion) {
|
||||
return null;
|
||||
}
|
||||
return this.identifier ? this.identifier : this.title;
|
||||
}
|
||||
|
||||
public get text(): string {
|
||||
return this.motion ? this.motion.text : null;
|
||||
}
|
||||
@ -184,6 +193,14 @@ export class ViewMotion extends BaseViewModel {
|
||||
return this._item;
|
||||
}
|
||||
|
||||
public get motion_block_id(): number {
|
||||
return this.motion ? this.motion.motion_block_id : null;
|
||||
}
|
||||
|
||||
public get motion_block(): MotionBlock {
|
||||
return this._block;
|
||||
}
|
||||
|
||||
public get agendaSpeakerAmount(): number {
|
||||
return this.item ? this.item.speakerAmount : null;
|
||||
}
|
||||
@ -195,7 +212,8 @@ export class ViewMotion extends BaseViewModel {
|
||||
supporters?: User[],
|
||||
workflow?: Workflow,
|
||||
state?: WorkflowState,
|
||||
item?: Item
|
||||
item?: Item,
|
||||
block?: MotionBlock
|
||||
) {
|
||||
super();
|
||||
|
||||
@ -206,6 +224,7 @@ export class ViewMotion extends BaseViewModel {
|
||||
this._workflow = workflow;
|
||||
this._state = state;
|
||||
this._item = item;
|
||||
this._block = block;
|
||||
|
||||
// TODO: Should be set using a a config variable
|
||||
this.lnMode = LineNumberingMode.Outside;
|
||||
@ -244,6 +263,8 @@ export class ViewMotion extends BaseViewModel {
|
||||
this.updateCategory(update as Category);
|
||||
} else if (update instanceof Item) {
|
||||
this.updateItem(update as Item);
|
||||
} else if (update instanceof MotionBlock) {
|
||||
this.updateMotionBlock(update);
|
||||
}
|
||||
// TODO: There is no way (yet) to add Submitters to a motion
|
||||
// Thus, this feature could not be tested
|
||||
@ -251,31 +272,41 @@ export class ViewMotion extends BaseViewModel {
|
||||
|
||||
/**
|
||||
* Update routine for the category
|
||||
* @param update potentially the changed category. Needs manual verification
|
||||
* @param category potentially the changed category. Needs manual verification
|
||||
*/
|
||||
public updateCategory(update: Category): void {
|
||||
if (this.motion && update.id === this.motion.category_id) {
|
||||
this._category = update as Category;
|
||||
public updateCategory(category: Category): void {
|
||||
if (this.motion && category.id === this.motion.category_id) {
|
||||
this._category = category;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update routine for the workflow
|
||||
* @param update potentially the changed workflow (state). Needs manual verification
|
||||
* @param workflow potentially the changed workflow (state). Needs manual verification
|
||||
*/
|
||||
public updateWorkflow(update: Workflow): void {
|
||||
if (this.motion && update.id === this.motion.workflow_id) {
|
||||
this._workflow = update as Workflow;
|
||||
public updateWorkflow(workflow: Workflow): void {
|
||||
if (this.motion && workflow.id === this.motion.workflow_id) {
|
||||
this._workflow = workflow;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update routine for the agenda Item
|
||||
* @param update potentially the changed agenda Item. Needs manual verification
|
||||
* @param item potentially the changed agenda Item. Needs manual verification
|
||||
*/
|
||||
public updateItem(update: Item): void {
|
||||
if (this.motion && update.id === this.motion.agenda_item_id) {
|
||||
this._item = update as Item;
|
||||
public updateItem(item: Item): void {
|
||||
if (this.motion && item.id === this.motion.agenda_item_id) {
|
||||
this._item = item;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update routine for the motion block
|
||||
* @param block potentially the changed motion block. Needs manual verification
|
||||
*/
|
||||
public updateMotionBlock(block: MotionBlock): void {
|
||||
if (this.motion && block.id === this.motion.motion_block_id) {
|
||||
this._block = block;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,20 @@
|
||||
import { TestBed, inject } from '@angular/core/testing';
|
||||
|
||||
import { E2EImportsModule } from 'e2e-imports.module';
|
||||
import { MotionCsvExportService } from './motion-csv-export.service';
|
||||
|
||||
describe('MotionCsvExportService', () => {
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [E2EImportsModule],
|
||||
providers: [MotionCsvExportService]
|
||||
});
|
||||
});
|
||||
|
||||
it('should be created', inject(
|
||||
[MotionCsvExportService],
|
||||
(service: MotionCsvExportService) => {
|
||||
expect(service).toBeTruthy();
|
||||
}
|
||||
));
|
||||
});
|
@ -0,0 +1,64 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
import { CsvExportService } from 'app/core/services/csv-export.service';
|
||||
import { ViewMotion } from '../models/view-motion';
|
||||
|
||||
/**
|
||||
* Exports CSVs for motions. Collect all CSV types here to have them in one place.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class MotionCsvExportService {
|
||||
|
||||
/**
|
||||
* Does nothing.
|
||||
*
|
||||
* @param csvExport CsvExportService
|
||||
* @param translate TranslateService
|
||||
*/
|
||||
public constructor(private csvExport: CsvExportService, private translate: TranslateService) {}
|
||||
|
||||
/**
|
||||
* Export all motions as CSV
|
||||
*
|
||||
* @param motions Motions to export
|
||||
*/
|
||||
public exportMotionList(motions: ViewMotion[]): void {
|
||||
this.csvExport.export(
|
||||
motions,
|
||||
[
|
||||
{ property: 'identifier' },
|
||||
{ property: 'title' },
|
||||
{ property: 'text' },
|
||||
{ property: 'reason' },
|
||||
{ property: 'submitters' },
|
||||
{ property: 'category' },
|
||||
{ property: 'origin' }
|
||||
],
|
||||
this.translate.instant('Motions') + '.csv'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports the call list.
|
||||
*
|
||||
* @param motions All motions in the CSV. They should be ordered by callListWeight correctly.
|
||||
*/
|
||||
public exportCallList(motions: ViewMotion[]): void {
|
||||
this.csvExport.export(
|
||||
motions,
|
||||
[
|
||||
{ label: 'Called', map: motion => motion.sort_parent_id ? '' : motion.identifierOrTitle },
|
||||
{ label: 'Called with', map: motion => !motion.sort_parent_id ? '' : motion.identifierOrTitle },
|
||||
{ label: 'submitters', map: motion => motion.submitters.map(s => s.short_name).join(',') },
|
||||
{ property: 'title' },
|
||||
{ label: 'recommendation', map: motion => motion.recommendation ? this.translate.instant(motion.recommendation.recommendation_label) : '' },
|
||||
{ property: 'motion_block', label: 'Motion block' }
|
||||
],
|
||||
this.translate.instant('Call list') + '.csv'
|
||||
);
|
||||
}
|
||||
}
|
@ -82,6 +82,12 @@ export class MotionRepositoryService extends BaseRepository<ViewMotion, Motion>
|
||||
return new ViewMotion(motion, category, submitters, supporters, workflow, state, item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add custom hook into the observables. The motions get a virtual weight (a sequential number) for the
|
||||
* call list order. One can just sort for this number instead of dealing with the sort parent id and weight.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
public getViewModelListObservable(): Observable<ViewMotion[]> {
|
||||
return super.getViewModelListObservable().pipe(
|
||||
tap(motions => {
|
||||
|
@ -99,7 +99,7 @@ export class UserListComponent extends ListViewBaseComponent<ViewUser> implement
|
||||
{ property: 'last_name', label: 'Surname' },
|
||||
{ property: 'structure_level', label: 'Structure level' },
|
||||
{ property: 'participant_number', label: 'Participant number' },
|
||||
{ property: 'groups', assemble: 'name' },
|
||||
{ label: 'groups', map: user => user.groups.map(group => group.name).join(',') },
|
||||
{ property: 'comment' },
|
||||
{ property: 'is_active', label: 'Is active' },
|
||||
{ property: 'is_present', label: 'Is present' },
|
||||
|
Loading…
Reference in New Issue
Block a user