Skip to content

Commit

Permalink
fix(export): add grouped header title (from pre-header) into exports (#…
Browse files Browse the repository at this point in the history
…436)

* fix(export): add grouped header title (from pre-header) into exports
- when having Grouped Header Titles (in pre-header), those were not showing in the export, now they do
  • Loading branch information
ghiscoding committed Apr 30, 2020
1 parent a746c2d commit a315f85
Show file tree
Hide file tree
Showing 6 changed files with 461 additions and 84 deletions.
4 changes: 1 addition & 3 deletions src/app/modules/angular-slickgrid/global-grid-options.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { DelimiterType, FileType, GridOption, OperatorType } from './models/index';
import { Filters } from './filters/index';

/**
* Options that can be passed to the Bootstrap-Datetimepicker directly
*/
/** Global Grid Options Defaults */
export const GlobalGridOptions: Partial<GridOption> = {
alwaysShowVerticalScroll: true,
autoEdit: false,
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ describe('ExportService', () => {
HUMAN_RESOURCES: 'Human Resources',
IT_ADMIN: 'IT Admin',
DEVELOPER: 'Developer',
COMPANY_PROFILE: 'Company Profile',
USER_PROFILE: 'User Profile',
SALES: 'Sales',
});
translate.setTranslation('fr', {
FIRST_NAME: 'Prénom',
Expand All @@ -109,6 +112,9 @@ describe('ExportService', () => {
HUMAN_RESOURCES: 'Ressources humaines',
IT_ADMIN: 'Administrateur IT',
DEVELOPER: 'Développeur',
COMPANY_PROFILE: 'Profile de compagnie',
USER_PROFILE: `Profile d'usager`,
SALES: 'Ventes',
});
translate.setDefaultLang('en');
translate.use('en');
Expand Down Expand Up @@ -947,6 +953,101 @@ describe('ExportService', () => {
});
});
});

describe('Grouped Column Header Titles', () => {
let mockCollection: any[];

beforeEach(() => {
mockGridOptions.createPreHeaderPanel = true;
mockGridOptions.showPreHeaderPanel = true;
mockGridOptions.exportOptions = { delimiterOverride: '' };
mockColumns = [
{ id: 'id', field: 'id', excludeFromExport: true },
{ id: 'firstName', field: 'firstName', width: 100, formatter: myBoldHtmlFormatter, columnGroup: 'User Profile' },
{ id: 'lastName', field: 'lastName', width: 100, columnGroup: 'User Profile', formatter: myBoldHtmlFormatter, exportCustomFormatter: myUppercaseFormatter, sanitizeDataExport: true, exportWithFormatter: true },
{ id: 'userId', field: 'userId', name: 'User Id', width: 100, exportCsvForceToKeepAsString: true, columnGroup: 'Company Profile' },
{ id: 'position', field: 'position', width: 100, columnGroup: 'Company Profile' },
{ id: 'order', field: 'order', width: 100, exportWithFormatter: true, columnGroup: 'Sales', formatter: Formatters.multiple, params: { formatters: [myBoldHtmlFormatter, myCustomObjectFormatter] } },
] as Column[];

jest.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns);
jest.spyOn(dataViewStub, 'getGrouping').mockReturnValue(null);
});

it('should export with grouped header titles showing up on first row', (done) => {
mockCollection = [{ id: 0, userId: '1E06', firstName: 'John', lastName: 'Z', position: 'SALES_REP', order: 10 }];
jest.spyOn(dataViewStub, 'getLength').mockReturnValue(mockCollection.length);
jest.spyOn(dataViewStub, 'getItem').mockReturnValue(null).mockReturnValueOnce(mockCollection[0]);
const spyOnAfter = jest.spyOn(service.onGridAfterExportToFile, 'next');
const spyUrlCreate = jest.spyOn(URL, 'createObjectURL');
const spyDownload = jest.spyOn(service, 'startDownloadFile');

const optionExpectation = { filename: 'export.csv', format: 'csv', useUtf8WithBom: false };
const contentExpectation =
`"User Profile","User Profile","Company Profile","Company Profile","Sales"
"FirstName","LastName","User Id","Position","Order"
"John","Z",="1E06","SALES_REP","<b>10</b>"`;

service.init(gridStub, dataViewStub);
service.exportToFile(mockExportCsvOptions);

setTimeout(() => {
expect(spyOnAfter).toHaveBeenCalledWith(optionExpectation);
expect(spyUrlCreate).toHaveBeenCalledWith(mockCsvBlob);
expect(spyDownload).toHaveBeenCalledWith({ ...optionExpectation, content: removeMultipleSpaces(contentExpectation) });
done();
});
});

describe('with Translation', () => {
let mockCollection2: any[];

beforeEach(() => {
mockGridOptions.enableTranslate = true;
mockGridOptions.i18n = translate;

mockColumns = [
{ id: 'id', field: 'id', excludeFromExport: true },
{ id: 'firstName', nameKey: 'FIRST_NAME', width: 100, columnGroupKey: 'USER_PROFILE', formatter: myBoldHtmlFormatter },
{ id: 'lastName', field: 'lastName', nameKey: 'LAST_NAME', width: 100, columnGroupKey: 'USER_PROFILE', formatter: myBoldHtmlFormatter, exportCustomFormatter: myUppercaseFormatter, sanitizeDataExport: true, exportWithFormatter: true },
{ id: 'userId', field: 'userId', name: 'User Id', width: 100, columnGroupKey: 'COMPANY_PROFILE', exportCsvForceToKeepAsString: true },
{ id: 'position', field: 'position', name: 'Position', width: 100, columnGroupKey: 'COMPANY_PROFILE', formatter: Formatters.translate, exportWithFormatter: true },
{ id: 'order', field: 'order', width: 100, exportWithFormatter: true, columnGroupKey: 'SALES', formatter: Formatters.multiple, params: { formatters: [myBoldHtmlFormatter, myCustomObjectFormatter] } },
] as Column[];
jest.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns);
});

afterEach(() => {
jest.clearAllMocks();
});

it(`should have the LastName header title translated when defined as a "headerKey" and "i18n" is set in grid option`, (done) => {
mockGridOptions.exportOptions.sanitizeDataExport = false;
mockCollection2 = [{ id: 0, userId: '1E06', firstName: 'John', lastName: 'Z', position: 'SALES_REP', order: 10 }];
jest.spyOn(dataViewStub, 'getLength').mockReturnValue(mockCollection2.length);
jest.spyOn(dataViewStub, 'getItem').mockReturnValue(null).mockReturnValueOnce(mockCollection2[0]);
const spyOnAfter = jest.spyOn(service.onGridAfterExportToFile, 'next');
const spyUrlCreate = jest.spyOn(URL, 'createObjectURL');
const spyDownload = jest.spyOn(service, 'startDownloadFile');

const optionExpectation = { filename: 'export.csv', format: 'csv', useUtf8WithBom: false };
const contentExpectation =
`"User Profile","User Profile","Company Profile","Company Profile","Sales"
"First Name","Last Name","User Id","Position","Order"
"John","Z",="1E06","Sales Rep.","<b>10</b>"`;

service.init(gridStub, dataViewStub);
service.exportToFile(mockExportCsvOptions);

setTimeout(() => {
expect(spyOnAfter).toHaveBeenCalledWith(optionExpectation);
expect(spyUrlCreate).toHaveBeenCalledWith(mockCsvBlob);
expect(spyDownload).toHaveBeenCalledWith({ ...optionExpectation, content: removeMultipleSpaces(contentExpectation) });
done();
});
});
});
});
});

describe('without ngx-translate', () => {
Expand Down
141 changes: 114 additions & 27 deletions src/app/modules/angular-slickgrid/services/excelExport.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ export class ExcelExportService {
private _dataView: any;
private _grid: any;
private _locales: Locale;
private _columnHeaders: KeyTitlePair[];
private _groupedHeaders: KeyTitlePair[];
private _columnHeaders: Array<KeyTitlePair>;
private _groupedColumnHeaders: Array<KeyTitlePair>;
private _hasGroupedItems = false;
private _excelExportOptions: ExcelExportOption;
private _sheet: ExcelWorksheet;
Expand Down Expand Up @@ -246,7 +246,16 @@ export class ExcelExportService {
columnHeaderStyleId = this._stylesheet.createFormat(columnHeaderStyle).id;
}

// get all column headers (it might include a "Group by" title at A1 cell)
// when having Grouped Header Titles (in the pre-header), then make the cell Bold & Aligned Center
const boldCenterAlign = this._stylesheet.createFormat({ alignment: { horizontal: 'center' }, font: { bold: true } });
const boldCenterAlignId = boldCenterAlign && boldCenterAlign.id;

// get all Grouped Column Header Titles when defined (from pre-header row)
if (this._gridOptions.createPreHeaderPanel && this._gridOptions.showPreHeaderPanel && !this._gridOptions.enableDraggableGrouping) {
outputData.push(this.getColumnGroupedHeaderTitlesData(columns, { style: boldCenterAlignId }));
}

// get all Column Header Titles (it might include a "Group by" title at A1 cell)
// also style the headers, defaults to Bold but user could pass his own style
outputData.push(this.getColumnHeaderData(columns, { style: columnHeaderStyleId }));

Expand Down Expand Up @@ -280,10 +289,60 @@ export class ExcelExportService {
return columnStyles;
}

/**
* Get all Grouped Header Titles and their keys, translate the title when required, and format them in Bold
* @param {Array<object>} columns of the grid
*/
private getColumnGroupedHeaderTitlesData(columns: Column[], metadata: ExcelMetadata): Array<ExcelCellFormat> {
let outputGroupedHeaderTitles: Array<ExcelCellFormat> = [];

// get all Column Header Titles
this._groupedColumnHeaders = this.getColumnGroupedHeaderTitles(columns) || [];
if (this._groupedColumnHeaders && Array.isArray(this._groupedColumnHeaders) && this._groupedColumnHeaders.length > 0) {
// add the header row + add a new line at the end of the row
outputGroupedHeaderTitles = this._groupedColumnHeaders.map((header) => ({ value: header.title, metadata }));
}

// merge necessary cells (any grouped header titles)
// dealing with the Excel column position is a bit tricky since the first 26 columns are single char (A,B,...) but after that it becomes double char (AA,AB,...)
// so we must first see if we are in the first section of 26 chars, if that is the case we just concatenate 1 (1st row) so it becomes (A1, B1, ...)
// but if we are over enumarating passed 26, we need an extra prefix (AA1, AB1, ...)
const charA = 'A'.charCodeAt(0);
let cellPositionStart = 'A';
let cellPositionEnd = '';
let lastIndex = 0;
const headersLn = this._groupedColumnHeaders.length;
for (let cellIndex = 0; cellIndex < headersLn; cellIndex++) {
// if we reached the last indenx, we are considered at the end
// else we check if next title is equal to current title, if so then we know it's a grouped header
// and we include it and continue looping until we reach the end
if ((cellIndex + 1) === headersLn || ((cellIndex + 1) < headersLn && this._groupedColumnHeaders[cellIndex].title !== this._groupedColumnHeaders[cellIndex + 1].title)) {
// calculate left prefix, divide by 26 and use modulo to find out what number add to A
// for example if we have cell index 54, we will do ((54/26) %26) => 2.0769, Math.floor is 2, then we do A which is 65 + 2 gives us B so final cell will be AB1
const leftCellCharCodePrefix = Math.floor((lastIndex / 26) % 26);
const leftCellCharacterPrefix = String.fromCharCode(charA + leftCellCharCodePrefix - 1);

const rightCellCharCodePrefix = Math.floor((cellIndex / 26) % 26);
const rightCellCharacterPrefix = String.fromCharCode(charA + rightCellCharCodePrefix - 1);

cellPositionEnd = String.fromCharCode(charA + (cellIndex % 26));
const leftCell = `${lastIndex > 26 ? leftCellCharacterPrefix : ''}${cellPositionStart}1`;
const rightCell = `${cellIndex > 26 ? rightCellCharacterPrefix : ''}${cellPositionEnd}1`;
this._sheet.mergeCells(leftCell, rightCell);

cellPositionStart = String.fromCharCode(cellPositionEnd.charCodeAt(0) + 1);
lastIndex = cellIndex;
}
}

return outputGroupedHeaderTitles;
}

/** Get all column headers and format them in Bold */
private getColumnHeaderData(columns: Column[], metadata: ExcelMetadata): string[] | ExcelCellFormat[] {
private getColumnHeaderData(columns: Column[], metadata: ExcelMetadata): Array<string | ExcelCellFormat> {
let outputHeaderTitles: ExcelCellFormat[] = [];

// get all Column Header Titles
this._columnHeaders = this.getColumnHeaders(columns) || [];
if (this._columnHeaders && Array.isArray(this._columnHeaders) && this._columnHeaders.length > 0) {
// add the header row + add a new line at the end of the row
Expand Down Expand Up @@ -320,35 +379,63 @@ export class ExcelExportService {
return null;
}

/**
* Get all Grouped Header Titles and their keys, translate the title when required.
* @param {Array<object>} columns of the grid
*/
private getColumnGroupedHeaderTitles(columns: Column[]): Array<KeyTitlePair> {
const groupedColumnHeaders: Array<KeyTitlePair> = [];

if (columns && Array.isArray(columns)) {
// Populate the Grouped Column Header, pull the columnGroup(Key) defined
columns.forEach((columnDef: Column) => {
let groupedHeaderTitle = '';
if (columnDef.columnGroupKey && this._gridOptions.enableTranslate && this.translate && this.translate.currentLang && this.translate.instant) {
groupedHeaderTitle = this.translate.instant(columnDef.columnGroupKey);
} else {
groupedHeaderTitle = columnDef.columnGroup;
}
const skippedField = columnDef.excludeFromExport || false;

// if column width is 0px, then we consider that field as a hidden field and should not be part of the export
if ((columnDef.width === undefined || columnDef.width > 0) && !skippedField) {
groupedColumnHeaders.push({
key: (columnDef.field || columnDef.id) as string,
title: groupedHeaderTitle || '',
});
}
});
}
return groupedColumnHeaders;
}

/**
* Get all header titles and their keys, translate the title when required.
* @param columns of the grid
*/
private getColumnHeaders(columns: Column[]): KeyTitlePair[] {
if (!columns || !Array.isArray(columns) || columns.length === 0) {
return null;
}
private getColumnHeaders(columns: Column[]): Array<KeyTitlePair> {
const columnHeaders = [];

// Populate the Column Header, pull the name defined
columns.forEach((columnDef) => {
let headerTitle = '';
if ((columnDef.headerKey || columnDef.nameKey) && this._gridOptions.enableTranslate && this.translate && this.translate.currentLang && this.translate.instant) {
headerTitle = this.translate.instant((columnDef.headerKey || columnDef.nameKey));
} else {
headerTitle = columnDef.name || titleCase(columnDef.field);
}
const skippedField = columnDef.excludeFromExport || false;

// if column width is 0, then we consider that field as a hidden field and should not be part of the export
if ((columnDef.width === undefined || columnDef.width > 0) && !skippedField) {
columnHeaders.push({
key: columnDef.field || columnDef.id,
title: headerTitle
});
}
});

if (columns && Array.isArray(columns)) {
// Populate the Column Header, pull the name defined
columns.forEach((columnDef) => {
let headerTitle = '';
if ((columnDef.headerKey || columnDef.nameKey) && this._gridOptions.enableTranslate && this.translate && this.translate.currentLang && this.translate.instant) {
headerTitle = this.translate.instant((columnDef.headerKey || columnDef.nameKey));
} else {
headerTitle = columnDef.name || titleCase(columnDef.field);
}
const skippedField = columnDef.excludeFromExport || false;

// if column width is 0, then we consider that field as a hidden field and should not be part of the export
if ((columnDef.width === undefined || columnDef.width > 0) && !skippedField) {
columnHeaders.push({
key: columnDef.field || columnDef.id,
title: headerTitle
});
}
});
}
return columnHeaders;
}

Expand Down

0 comments on commit a315f85

Please sign in to comment.