From a973ad171914591b48c63116960af26a546825e1 Mon Sep 17 00:00:00 2001 From: Sean Engelhardt Date: Tue, 26 Mar 2019 17:32:37 +0100 Subject: [PATCH] Export motions as excel document (.xlsx) Adds exceljs library. Extends the "motion export dialog" to support xlsx export with a specific set of rules similar to CSV --- client/package.json | 3 +- .../xlsx-export-service.service.spec.ts | 12 +++ .../xlsx-export-service.service.ts | 81 +++++++++++++++++++ .../motion-export-dialog.component.html | 3 +- .../motion-export-dialog.component.ts | 14 +++- .../motion-list/motion-list.component.ts | 6 +- .../motion-xlsx-export.service.spec.ts | 17 ++++ .../services/motion-xlsx-export.service.ts | 71 ++++++++++++++++ client/tsconfig.json | 5 +- 9 files changed, 206 insertions(+), 6 deletions(-) create mode 100644 client/src/app/core/ui-services/xlsx-export-service.service.spec.ts create mode 100644 client/src/app/core/ui-services/xlsx-export-service.service.ts create mode 100644 client/src/app/site/motions/services/motion-xlsx-export.service.spec.ts create mode 100644 client/src/app/site/motions/services/motion-xlsx-export.service.ts diff --git a/client/package.json b/client/package.json index 18b3758ce..5e570669e 100644 --- a/client/package.json +++ b/client/package.json @@ -43,6 +43,7 @@ "@tinymce/tinymce-angular": "^3.0.0", "core-js": "^2.6.5", "css-element-queries": "^1.1.1", + "exceljs": "1.8.0", "file-saver": "^2.0.1", "hammerjs": "^2.0.8", "material-icon-font": "git+https://github.com/petergng/materialIconFont.git", @@ -84,7 +85,7 @@ "terser": "3.16.1", "ts-node": "~8.0.2", "tslint": "~5.12.1", - "tsutils": "^3.8.0", + "tsutils": "3.8.0", "typescript": "~3.2.0", "webpack-bundle-analyzer": "^3.0.4" } diff --git a/client/src/app/core/ui-services/xlsx-export-service.service.spec.ts b/client/src/app/core/ui-services/xlsx-export-service.service.spec.ts new file mode 100644 index 000000000..efa280dfb --- /dev/null +++ b/client/src/app/core/ui-services/xlsx-export-service.service.spec.ts @@ -0,0 +1,12 @@ +import { TestBed } from '@angular/core/testing'; + +import { XlsxExportServiceService } from './xlsx-export-service.service'; + +describe('XlsxExportServiceService', () => { + beforeEach(() => TestBed.configureTestingModule({})); + + it('should be created', () => { + const service: XlsxExportServiceService = TestBed.get(XlsxExportServiceService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/core/ui-services/xlsx-export-service.service.ts b/client/src/app/core/ui-services/xlsx-export-service.service.ts new file mode 100644 index 000000000..8cb288845 --- /dev/null +++ b/client/src/app/core/ui-services/xlsx-export-service.service.ts @@ -0,0 +1,81 @@ +import { Injectable } from '@angular/core'; + +import { Worksheet, Workbook } from 'exceljs'; +import { saveAs } from 'file-saver'; + +@Injectable({ + providedIn: 'root' +}) +export class XlsxExportServiceService { + /** + * Correction factor for cell width alignment + */ + private PIXELS_PER_EXCEL_WIDTH_UNIT = 7.5; + + /** + * Constructor + */ + public constructor() {} + + /** + * Saves the given workflow + * + * @param workbook The workflow to export + * @param fileName The filename to save to workflow to + */ + public saveXlsx(workbook: Workbook, fileName: string): void { + workbook.xlsx.writeBuffer().then(blobData => { + const blob = new Blob([blobData as BlobPart], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + }); + saveAs(blob, `${fileName}.xlsx`); + }); + } + + /** + * helper function to automatically resize the columns of the given Worksheet. + * Will manipulate the parameter. + * TODO: Upstream ExcelJS issue for auto column width: + * https://github.com/exceljs/exceljs/issues/83 + * + * @param sheet The sheet to resize + * @param fromRow the row number to start detecting the size + */ + public autoSize(sheet: Worksheet, fromRow: number): void { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (!ctx) { + return; + } + + const maxColumnLengths: Array = []; + sheet.eachRow((row, rowNum) => { + if (rowNum < fromRow) { + return; + } + + row.eachCell((cell, num) => { + if (typeof cell.value === 'string') { + if (maxColumnLengths[num] === undefined) { + maxColumnLengths[num] = 0; + } + + const fontSize = cell.font && cell.font.size ? cell.font.size : 11; + ctx.font = `${fontSize}pt Arial`; + const metrics = ctx.measureText(cell.value); + const cellWidth = metrics.width; + + maxColumnLengths[num] = Math.max(maxColumnLengths[num], cellWidth); + } + }); + }); + + for (let i = 1; i <= sheet.columnCount; i++) { + const col = sheet.getColumn(i); + const width = maxColumnLengths[i]; + if (width) { + col.width = width / this.PIXELS_PER_EXCEL_WIDTH_UNIT + 1; + } + } + } +} diff --git a/client/src/app/site/motions/modules/motion-list/components/motion-export-dialog/motion-export-dialog.component.html b/client/src/app/site/motions/modules/motion-list/components/motion-export-dialog/motion-export-dialog.component.html index e9666371e..6b5a49951 100644 --- a/client/src/app/site/motions/modules/motion-list/components/motion-export-dialog/motion-export-dialog.component.html +++ b/client/src/app/site/motions/modules/motion-list/components/motion-export-dialog/motion-export-dialog.component.html @@ -8,6 +8,7 @@ PDF CSV + XLSX @@ -52,7 +53,7 @@ Tags Origin Motion block - + Voting result Sequential number diff --git a/client/src/app/site/motions/modules/motion-list/components/motion-export-dialog/motion-export-dialog.component.ts b/client/src/app/site/motions/modules/motion-list/components/motion-export-dialog/motion-export-dialog.component.ts index 49c7093ec..19c3270f9 100644 --- a/client/src/app/site/motions/modules/motion-list/components/motion-export-dialog/motion-export-dialog.component.ts +++ b/client/src/app/site/motions/modules/motion-list/components/motion-export-dialog/motion-export-dialog.component.ts @@ -113,7 +113,17 @@ export class MotionExportDialogComponent implements OnInit { */ public ngOnInit(): void { this.exportForm.get('format').valueChanges.subscribe((value: string) => { - if (value === 'csv') { + // disable content for xslx + if (value === 'xlsx') { + // disable the content selection + this.exportForm.get('content').disable(); + // remove the selection of "content" + this.exportForm.get('content').setValue(null); + } else { + this.exportForm.get('content').enable(); + } + + if (value === 'csv' || value === 'xlsx') { // disable and deselect "lnMode" this.exportForm.get('lnMode').setValue(this.lnMode.None); this.exportForm.get('lnMode').disable(); @@ -140,7 +150,7 @@ export class MotionExportDialogComponent implements OnInit { // remove the selection of "votingResult" let metaInfoVal: string[] = this.exportForm.get('metaInfo').value; metaInfoVal = metaInfoVal.filter(info => { - return info !== 'votingResult'; + return info !== 'polls'; }); this.exportForm.get('metaInfo').setValue(metaInfoVal); diff --git a/client/src/app/site/motions/modules/motion-list/components/motion-list/motion-list.component.ts b/client/src/app/site/motions/modules/motion-list/components/motion-list/motion-list.component.ts index ad0b04eeb..aa35c6401 100644 --- a/client/src/app/site/motions/modules/motion-list/components/motion-list/motion-list.component.ts +++ b/client/src/app/site/motions/modules/motion-list/components/motion-list/motion-list.component.ts @@ -27,6 +27,7 @@ import { MotionFilterListService } from 'app/site/motions/services/motion-filter import { MotionCsvExportService } from 'app/site/motions/services/motion-csv-export.service'; import { MotionPdfExportService } from 'app/site/motions/services/motion-pdf-export.service'; import { MotionMultiselectService } from 'app/site/motions/services/motion-multiselect.service'; +import { MotionXlsxExportService } from 'app/site/motions/services/motion-xlsx-export.service'; import { LocalPermissionsService } from 'app/site/motions/services/local-permissions.service'; import { StorageService } from 'app/core/core-services/storage.service'; @@ -106,7 +107,8 @@ export class MotionListComponent extends ListViewBaseComponent { + beforeEach(() => + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }) + ); + + it('should be created', () => { + const service: MotionXlsxExportService = TestBed.get(MotionXlsxExportService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/motions/services/motion-xlsx-export.service.ts b/client/src/app/site/motions/services/motion-xlsx-export.service.ts new file mode 100644 index 000000000..00f1c1913 --- /dev/null +++ b/client/src/app/site/motions/services/motion-xlsx-export.service.ts @@ -0,0 +1,71 @@ +import { Injectable } from '@angular/core'; + +import { Workbook } from 'exceljs'; + +import { InfoToExport } from './motion-pdf.service'; +import { ViewMotion } from '../models/view-motion'; +import { XlsxExportServiceService } from 'app/core/ui-services/xlsx-export-service.service'; +import { TranslateService } from '@ngx-translate/core'; + +/** + * Service to export motion elements to XLSX + */ +@Injectable({ + providedIn: 'root' +}) +export class MotionXlsxExportService { + /** + * Constructor + */ + public constructor(private xlsx: XlsxExportServiceService, private translate: TranslateService) {} + + /** + * Export motions as XLSX + * + * @param motions + * @param contentToExport + * @param infoToExport + */ + public exportMotionList(motions: ViewMotion[], infoToExport: InfoToExport[]): void { + const workbook = new Workbook(); + const worksheet = workbook.addWorksheet(this.translate.instant('Motions')); + const properties = ['identifier', 'title'].concat(infoToExport); + + // if the ID was exported as well, shift it to the first position + if (properties[properties.length - 1] === 'id') { + properties.unshift(properties.pop()); + } + + worksheet.columns = properties.map(property => { + return { + header: this.translate.instant(property.charAt(0).toLocaleUpperCase() + property.slice(1)) + }; + }); + + // style the header row + worksheet.getRow(1).font = { + underline: true, + bold: true + }; + + // map motion data to properties + const motionData = motions.map(motion => + properties.map(property => { + const motionProp = motion[property]; + if (motionProp) { + return this.translate.instant(motionProp.toString()); + } else { + return null; + } + }) + ); + + // add to sheet + for (const motion of motionData) { + worksheet.addRow(motion); + } + + this.xlsx.autoSize(worksheet, 0); + this.xlsx.saveXlsx(workbook, this.translate.instant('Motions')); + } +} diff --git a/client/tsconfig.json b/client/tsconfig.json index 83f8ab6fd..db888be9b 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -10,6 +10,9 @@ "experimentalDecorators": true, "target": "es5", "typeRoots": ["node_modules/@types"], - "lib": ["es2017", "dom"] + "lib": ["es2017", "dom"], + "paths": { + "exceljs": ["../node_modules/exceljs/dist/exceljs.min"] + } } }