Skip to content
This repository was archived by the owner on Jun 1, 2025. It is now read-only.

Commit fb6a6f3

Browse files
authored
Merge pull request #306 from ghiscoding/feat/export-csv-list-separator-detection
feat(export): add delimiter/listSeparator override to use with GraphQL
2 parents 7550262 + ba6082c commit fb6a6f3

12 files changed

+187
-34
lines changed

src/app/app.module.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { TranslateModule, TranslateLoader, TranslateService } from '@ngx-transla
1010
import { TranslateHttpLoader } from '@ngx-translate/http-loader';
1111

1212
import { AppComponent } from './app.component';
13+
import { CustomActionFormatterComponent } from './examples/custom-actionFormatter.component';
1314
import { CustomTitleFormatterComponent } from './examples/custom-titleFormatter.component';
1415
import { EditorNgSelectComponent } from './examples/editor-ng-select.component';
1516
import { FilterNgSelectComponent } from './examples/filter-ng-select.component';
@@ -77,6 +78,7 @@ export function appInitializerFactory(translate: TranslateService, injector: Inj
7778
@NgModule({
7879
declarations: [
7980
AppComponent,
81+
CustomActionFormatterComponent,
8082
CustomTitleFormatterComponent,
8183
EditorNgSelectComponent,
8284
FilterNgSelectComponent,
@@ -137,6 +139,7 @@ export function appInitializerFactory(translate: TranslateService, injector: Inj
137139
],
138140
entryComponents: [
139141
// dynamically created components
142+
CustomActionFormatterComponent,
140143
CustomTitleFormatterComponent,
141144
EditorNgSelectComponent,
142145
FilterNgSelectComponent,
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Component } from '@angular/core';
2+
3+
@Component({
4+
template: `<div id="myDrop" class="dropdown" style="position:absolute; z-index:12000;">
5+
<button class="btn btn-default btn-xs dropdown-toggle" type="button" id="dropdownMenu1" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
6+
Action
7+
<span class="caret"></span>
8+
</button>
9+
<ul class="dropdown-menu" aria-labelledby="dropdownMenu1">
10+
<li><a class="pointer" (click)="parent.deleteCell(row)">Delete Row</a></li>
11+
</ul></div>`
12+
})
13+
export class CustomActionFormatterComponent {
14+
parent: any;
15+
}

src/app/examples/grid-angular.component.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ <h2>{{title}}</h2>
4848
(sgOnCellChange)="onCellChanged($event.detail.eventData, $event.detail.args)"
4949
(sgOnClick)="onCellClicked($event.detail.eventData, $event.detail.args)"
5050
(sgOnValidationError)="onCellValidation($event.detail.eventData, $event.detail.args)"
51+
(sgOnActiveCellChanged)="onActiveCellChanged($event.detail.eventData, $event.detail.args)"
5152
[columnDefinitions]="columnDefinitions" [gridOptions]="gridOptions" [dataset]="dataset">
5253
</angular-slickgrid>
5354
</div>

src/app/examples/grid-angular.component.ts

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ import {
77
Editors,
88
FieldType,
99
Filters,
10+
Formatter,
1011
Formatters,
1112
GridOption,
1213
OnEventArgs,
1314
} from './../modules/angular-slickgrid';
1415
import { EditorNgSelectComponent } from './editor-ng-select.component';
16+
import { CustomActionFormatterComponent } from './custom-actionFormatter.component';
1517
import { CustomAngularComponentEditor } from './custom-angularComponentEditor';
1618
import { CustomAngularComponentFilter } from './custom-angularComponentFilter';
1719
import { CustomTitleFormatterComponent } from './custom-titleFormatter.component';
@@ -23,6 +25,17 @@ declare var $: any;
2325

2426
const NB_ITEMS = 100;
2527

28+
const customActionFormatter: Formatter = (row: number, cell: number, value: any, columnDef: Column, dataContext: any, grid: any) => {
29+
// use the same button text "Action" as the "CustomActionFormatterComponent" button text
30+
// we basically recreate a dropdown on top of this one here which is just an empty one to show something in the grid
31+
return `<div id="myDrop-r${row}-c${cell}" class="dropdown">
32+
<button class="btn btn-default btn-xs dropdown-toggle" type="button">
33+
Action
34+
<span class="caret"></span>
35+
</button>
36+
</div>`;
37+
};
38+
2639
@Component({
2740
templateUrl: './grid-angular.component.html',
2841
styleUrls: ['./grid-angular.component.scss'],
@@ -218,7 +231,8 @@ export class GridAngularComponent implements OnInit {
218231
editor: {
219232
model: Editors.date
220233
},
221-
}
234+
},
235+
{ id: 'action', name: 'Action', field: 'id', formatter: customActionFormatter, width: 70 }
222236
];
223237

224238
this.gridOptions = {
@@ -329,8 +343,46 @@ export class GridAngularComponent implements OnInit {
329343
const componentOutput = this.angularUtilService.createAngularComponent(colDef.params.component);
330344
Object.assign(componentOutput.componentRef.instance, { item: dataContext });
331345

332-
// use a delay to make sure Angular ran at least a full cycle and it finished rendering the Component
346+
// use a delay to make sure Angular ran at least a full cycle and make sure it finished rendering the Component
333347
setTimeout(() => $(cellNode).empty().html(componentOutput.domElement));
334348
}
335349
}
350+
351+
/* Create an Action Dropdown Menu */
352+
deleteCell(rowNumber: number) {
353+
const item = this.angularGrid.dataView.getItem(rowNumber);
354+
this.angularGrid.gridService.deleteItemById(item.id);
355+
}
356+
357+
onActiveCellChanged(event, args) {
358+
if (args.cell !== 6) {
359+
return; // don't do anything unless it's the Action column which is at position 6 in this grid
360+
}
361+
362+
$('#myDrop').remove(); // make sure to remove previous Action dropdown, you don't want to have 100 after a 100 clicks...
363+
const cell = args.cell;
364+
const row = args.row;
365+
366+
// hide the dropdown we created as a Formatter, we'll redisplay it later
367+
const cellPos = $(`#myDrop-r${row}-c${cell}`).offset();
368+
369+
const componentOutput = this.angularUtilService.createAngularComponent(CustomActionFormatterComponent);
370+
371+
// pass "this" and the row number to the Component instance (CustomActionFormatter) so that we can call "parent.deleteCell(row)" with (click)
372+
Object.assign(componentOutput.componentRef.instance, { parent: this, row: args.row });
373+
374+
// use a delay to make sure Angular ran at least a full cycle and make sure it finished rendering the Component before using it
375+
setTimeout(() => {
376+
const elm = $(componentOutput.domElement);
377+
elm.appendTo('body');
378+
elm.css('position', 'absolute');
379+
elm.css('top', cellPos.top + 5);
380+
elm.css('left', cellPos.left);
381+
$('#myDrop').addClass('open');
382+
383+
$('#myDrop').on('hidden.bs.dropdown', () => {
384+
$(`#myDrop-r${row}-c${cell}`).show();
385+
});
386+
});
387+
}
336388
}

src/app/modules/angular-slickgrid/components/angular-slickgrid.component.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -398,10 +398,17 @@ export class AngularSlickgridComponent implements AfterViewInit, OnDestroy, OnIn
398398
if (backendApi) {
399399
// internalPostProcess only works (for now) with a GraphQL Service, so make sure it is that type
400400
if (backendApi && backendApi.service instanceof GraphqlService) {
401-
backendApi.internalPostProcess = (processResult: any) => {
401+
backendApi.internalPostProcess = (processResult: GraphqlResult) => {
402402
const datasetName = (backendApi && backendApi.service && typeof backendApi.service.getDatasetName === 'function') ? backendApi.service.getDatasetName() : '';
403403
if (processResult && processResult.data && processResult.data[datasetName]) {
404404
this._dataset = processResult.data[datasetName].nodes;
405+
if (processResult.data[datasetName].listSeparator) {
406+
// if the "listSeparator" is available in the GraphQL result, we'll override the ExportOptions Delimiter with this new info
407+
if (!this.gridOptions.exportOptions) {
408+
this.gridOptions.exportOptions = {};
409+
}
410+
this.gridOptions.exportOptions.delimiterOverride = processResult.data[datasetName].listSeparator.toString();
411+
}
405412
this.refreshGridData(this._dataset, processResult.data[datasetName].totalCount);
406413
} else {
407414
this._dataset = [];

src/app/modules/angular-slickgrid/models/exportOption.interface.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ export interface ExportOption {
44
/** export delimiter, can be (comma, tab, ... or even custom string). */
55
delimiter?: DelimiterType | string;
66

7+
/** Allows you to override for the export delimiter, useful when adding the "listSeparator" to the GraphQL query */
8+
delimiterOverride?: DelimiterType | string;
9+
710
/** Defaults to false, which leads to all Formatters of the grid being evaluated on export. You can also override a column by changing the propery on the column itself */
811
exportWithFormatter?: boolean;
912

src/app/modules/angular-slickgrid/models/graphqlResult.interface.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
1+
import { DelimiterType } from './delimiterType.enum';
12
import { Metrics } from './metrics.interface';
23
import { Statistic } from './statistic.interface';
34

45
export interface GraphqlResult {
56
data: {
67
[datasetName: string]: {
7-
nodes: any[],
8+
nodes: any[];
89
pageInfo: {
910
hasNextPage: boolean;
10-
},
11-
totalCount: number
11+
};
12+
listSeparator?: DelimiterType;
13+
totalCount: number;
1214
}
1315
};
1416

src/app/modules/angular-slickgrid/models/graphqlServiceOption.interface.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,17 @@ import { GraphqlPaginationOption } from './graphqlPaginationOption.interface';
77

88
export interface GraphqlServiceOption extends BackendServiceOption {
99
/**
10-
* When using Translation, we probably want to add locale in the query for the filterBy/orderBy to work
11-
* ex.: users(first: 10, offset: 0, locale: "en-CA", filterBy: [{field: name, operator: EQ, value:"John"}]) {
10+
* When using Translation, we probably want to add locale as a query parameter for the filterBy/orderBy to work
11+
* ex.: users(first: 10, offset: 0, locale: "en-CA", filterBy: [{field: name, operator: EQ, value:"John"}]) { }
1212
*/
1313
addLocaleIntoQuery?: boolean;
1414

15+
/**
16+
* Add the Current User List Separator to the result query (in English the separator is comma ",").
17+
* This is useful to set the "delimiter" property when using Export CSV, for example French uses semicolon ";" as a delimiter/separator
18+
*/
19+
addListSeparator?: boolean;
20+
1521
/** What is the dataset, this is required for the GraphQL query to be built */
1622
datasetName?: string;
1723

src/app/modules/angular-slickgrid/services/__tests__/export.service.spec.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,10 @@ describe('ExportService', () => {
250250
describe('startDownloadFile call after all private methods ran ', () => {
251251
let mockCollection: any[];
252252

253+
beforeEach(() => {
254+
mockGridOptions.exportOptions = { delimiterOverride: '' };
255+
});
256+
253257
it(`should have the Order exported correctly with multiple formatters which have 1 of them returning an object with a text property (instead of simple string)`, (done) => {
254258
mockCollection = [{ id: 0, userId: '1E06', firstName: 'John', lastName: 'Z', position: 'SALES_REP', order: 10 }];
255259
jest.spyOn(dataViewStub, 'getLength').mockReturnValue(mockCollection.length);
@@ -274,6 +278,31 @@ describe('ExportService', () => {
274278
});
275279
});
276280

281+
it(`should have the Order exported correctly with multiple formatters and use a different delimiter when "delimiterOverride" is provided`, (done) => {
282+
mockGridOptions.exportOptions = { delimiterOverride: DelimiterType.doubleSemicolon };
283+
mockCollection = [{ id: 0, userId: '1E06', firstName: 'John', lastName: 'Z', position: 'SALES_REP', order: 10 }];
284+
jest.spyOn(dataViewStub, 'getLength').mockReturnValue(mockCollection.length);
285+
jest.spyOn(dataViewStub, 'getItem').mockReturnValue(null).mockReturnValueOnce(mockCollection[0]);
286+
const spyOnAfter = jest.spyOn(service.onGridAfterExportToFile, 'next');
287+
const spyUrlCreate = jest.spyOn(URL, 'createObjectURL');
288+
const spyDownload = jest.spyOn(service, 'startDownloadFile');
289+
290+
const optionExpectation = { filename: 'export.csv', format: 'csv', useUtf8WithBom: false };
291+
const contentExpectation =
292+
`"User Id";;"FirstName";;"LastName";;"Position";;"Order"
293+
="1E06";;"John";;"Z";;"SALES_REP";;"<b>10</b>"`;
294+
295+
service.init(gridStub, dataViewStub);
296+
service.exportToFile(mockExportCsvOptions);
297+
298+
setTimeout(() => {
299+
expect(spyOnAfter).toHaveBeenCalledWith(optionExpectation);
300+
expect(spyUrlCreate).toHaveBeenCalledWith(mockCsvBlob);
301+
expect(spyDownload).toHaveBeenCalledWith({ ...optionExpectation, content: removeMultipleSpaces(contentExpectation) });
302+
done();
303+
});
304+
});
305+
277306
it(`should have the UserId escape with equal sign showing as prefix, to avoid Excel casting the value 1E06 to 1 exponential 6,
278307
when "exportCsvForceToKeepAsString" is enable in its column definition`, (done) => {
279308
mockCollection = [{ id: 0, userId: '1E06', firstName: 'John', lastName: 'Z', position: 'SALES_REP', order: 10 }];

0 commit comments

Comments
 (0)