Merge pull request #4036 from FinnStutzenstein/calllist_export

Call list csv export
This commit is contained in:
Finn Stutzenstein 2018-11-29 08:47:38 +01:00 committed by GitHub
commit 86e858c800
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 261 additions and 87 deletions

View File

@ -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);
}
/**

View File

@ -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>

View File

@ -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);
}
}

View File

@ -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>

View File

@ -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);
}
/**

View File

@ -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;
}
}

View File

@ -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();
}
));
});

View File

@ -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'
);
}
}

View File

@ -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 => {

View File

@ -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' },