Skip to content

Commit

Permalink
feat: inbuilt csv download support for tables (#2420)
Browse files Browse the repository at this point in the history
  • Loading branch information
arjunlalb authored Oct 19, 2023
1 parent 07dfe4d commit 101357c
Show file tree
Hide file tree
Showing 23 changed files with 471 additions and 13 deletions.
9 changes: 9 additions & 0 deletions projects/components/src/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,15 @@ export * from './table/cells/data-parsers/table-cell-icon-parser';
export * from './table/controls/table-controls.module';
export * from './table/controls/table-controls.component';

// CSV Generators
export * from './table/table-csv-downloader.service';
export * from './table/cells/csv-generators/table-cell-boolean-csv-generator';
export * from './table/cells/csv-generators/table-cell-number-csv-generator';
export * from './table/cells/csv-generators/table-cell-string-csv-generator';
export * from './table/cells/csv-generators/table-cell-timestamp-csv-generator';
export * from './table/cells/csv-generators/table-cell-string-array-csv-generator';
export * from './table/cells/table-cell-csv-generator';

// TextArea
export * from './textarea/textarea.component';
export * from './textarea/textarea.module';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { createServiceFactory } from '@ngneat/spectator/jest';
import { TableCellBooleanCsvGenerator } from './table-cell-boolean-csv-generator';

describe('TableCellBooleanCsvGenerator', () => {
const createService = createServiceFactory({
service: TableCellBooleanCsvGenerator
});

test('should return data as expected', () => {
const spectator = createService();
expect(spectator.service.generateSafeCsv(undefined)).toBeUndefined();
expect(spectator.service.generateSafeCsv(null)).toBeUndefined();

expect(spectator.service.generateSafeCsv(true)).toEqual('true');
expect(spectator.service.generateSafeCsv(false)).toEqual('false');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { TableCellCsvGenerator } from '../table-cell-csv-generator';
import { Injectable } from '@angular/core';
import { isNil } from 'lodash-es';

@Injectable({ providedIn: 'root' })
export class TableCellBooleanCsvGenerator implements TableCellCsvGenerator<boolean> {
public readonly cellType: string = 'boolean';

public generateSafeCsv(cellData: boolean | null | undefined): string | undefined {
return isNil(cellData) ? undefined : String(cellData);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { createServiceFactory } from '@ngneat/spectator/jest';
import { TableCellNumberCsvGenerator } from './table-cell-number-csv-generator';

describe('TableCellNumberCsvGenerator', () => {
const createService = createServiceFactory({
service: TableCellNumberCsvGenerator
});

test('should return data as expected', () => {
const spectator = createService();
expect(spectator.service.generateSafeCsv(undefined)).toBeUndefined();
expect(spectator.service.generateSafeCsv(null)).toBeUndefined();

expect(spectator.service.generateSafeCsv(1)).toEqual('1');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { TableCellCsvGenerator } from '../table-cell-csv-generator';
import { CoreTableCellRendererType } from '../types/core-table-cell-renderer-type';
import { Injectable } from '@angular/core';
import { isNil } from 'lodash-es';

@Injectable({ providedIn: 'root' })
export class TableCellNumberCsvGenerator implements TableCellCsvGenerator<number> {
public readonly cellType: string = CoreTableCellRendererType.Number;
public generateSafeCsv(cellData: number | undefined | null): string | undefined {
return isNil(cellData) ? undefined : cellData.toString();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { createServiceFactory } from '@ngneat/spectator/jest';
import { TableCellStringArrayCsvGenerator } from './table-cell-string-array-csv-generator';

describe('TableCellStringArrayCsvGenerator', () => {
const createService = createServiceFactory({
service: TableCellStringArrayCsvGenerator
});

test('should return data as expected', () => {
const spectator = createService();
expect(spectator.service.generateSafeCsv(undefined)).toBeUndefined();
expect(spectator.service.generateSafeCsv(null)).toBeUndefined();

expect(spectator.service.generateSafeCsv(['a'])).toEqual('a');
expect(spectator.service.generateSafeCsv(['a', 'b'])).toEqual('a, b');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { TableCellCsvGenerator } from '../table-cell-csv-generator';
import { CoreTableCellRendererType } from '../types/core-table-cell-renderer-type';
import { Injectable } from '@angular/core';
import { isNil } from 'lodash-es';

@Injectable({ providedIn: 'root' })
export class TableCellStringArrayCsvGenerator implements TableCellCsvGenerator<string[]> {
public readonly cellType: string = CoreTableCellRendererType.StringArray;

public generateSafeCsv(cellData: string[] | null | undefined): string | undefined {
return isNil(cellData) ? undefined : cellData.join(', ');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { createServiceFactory } from '@ngneat/spectator/jest';
import { TableCellStringCsvGenerator } from './table-cell-string-csv-generator';

describe('TableCellStringCsvGenerator', () => {
const createService = createServiceFactory({
service: TableCellStringCsvGenerator
});

test('should return data as expected', () => {
const spectator = createService();
expect(spectator.service.generateSafeCsv(undefined)).toBeUndefined();
expect(spectator.service.generateSafeCsv(null)).toBeUndefined();

expect(spectator.service.generateSafeCsv('test')).toEqual('test');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { TableCellCsvGenerator } from '../table-cell-csv-generator';
import { Injectable } from '@angular/core';
import { CoreTableCellRendererType } from '../types/core-table-cell-renderer-type';

@Injectable({ providedIn: 'root' })
export class TableCellStringCsvGenerator implements TableCellCsvGenerator<string> {
public cellType: string = CoreTableCellRendererType.Text;

public generateSafeCsv(cellData: string | null | undefined): string | undefined {
return cellData ?? undefined;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { createServiceFactory } from '@ngneat/spectator/jest';
import { TableCellTimestampCsvGenerator } from './table-cell-timestamp-csv-generator';
import { DateFormatMode, DisplayDatePipe } from '@hypertrace/common';

describe('TableCellTimestampCsvGenerator', () => {
const createService = createServiceFactory({
service: TableCellTimestampCsvGenerator
});

test('should return data as expected', () => {
const displayDatePipe = new DisplayDatePipe();
const spectator = createService();
expect(spectator.service.generateSafeCsv(undefined)).toEqual('-');
expect(spectator.service.generateSafeCsv(null)).toEqual('-');

expect(spectator.service.generateSafeCsv(new Date('2021-05-25T15:53:27.376Z'))).toEqual(
displayDatePipe.transform(new Date('2021-05-25T15:53:27.376Z'), { mode: DateFormatMode.TimeWithSeconds })
);
expect(spectator.service.generateSafeCsv(1697654315)).toEqual(
displayDatePipe.transform(1697654315, { mode: DateFormatMode.TimeWithSeconds })
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { TableCellCsvGenerator } from '../table-cell-csv-generator';
import { DateFormatMode, DisplayDatePipe } from '@hypertrace/common';
import { CoreTableCellRendererType } from '../types/core-table-cell-renderer-type';
import { Injectable } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class TableCellTimestampCsvGenerator implements TableCellCsvGenerator<Date | number | string> {
public cellType: string = CoreTableCellRendererType.Timestamp;
private readonly displayDate: DisplayDatePipe = new DisplayDatePipe();

public generateSafeCsv(cellData: Date | number | string | null | undefined): string {
return this.displayDate.transform(cellData, { mode: DateFormatMode.TimeWithSeconds });
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Injectable } from '@angular/core';
import { TableCellCsvGenerator } from './table-cell-csv-generator';
import { isArray, isUndefined } from 'lodash-es';

@Injectable({
providedIn: 'root'
})
export class TableCellCsvGeneratorManagementService {
private readonly csvGenerators: TableCellCsvGenerator<unknown>[] = [];

public register(csvGenerators: TableCellCsvGenerator<unknown> | TableCellCsvGenerator<unknown>[]): void {
isArray(csvGenerators) ? csvGenerators.forEach(item => this.addGenerator(item)) : this.addGenerator(csvGenerators);
}

private addGenerator(csvGenerator: TableCellCsvGenerator<unknown>): void {
if (isUndefined(this.findMatchingGenerator(csvGenerator.cellType))) {
this.csvGenerators.push(csvGenerator);
}
}

public findMatchingGenerator(type?: string): TableCellCsvGenerator<unknown> | undefined {
return this.csvGenerators.find(generator => generator.cellType === type);
}
}
16 changes: 16 additions & 0 deletions projects/components/src/table/cells/table-cell-csv-generator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Dictionary } from '@hypertrace/common';
import { InjectionToken } from '@angular/core';
import { TableRow } from '../table-api';

export const TABLE_CELL_CSV_GENERATORS = new InjectionToken<TableCellCsvGenerator<unknown>[][]>(
'TABLE_CELL_CSV_GENERATORS'
);

export interface TableCellCsvGenerator<TCellData, TRowData = TableRow> {
readonly cellType: string;

generateSafeCsv(
cellData: TCellData | null | undefined,
rowData?: TRowData | null
): string | Dictionary<string> | undefined;
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export abstract class TableCellRendererBase<TCellData, TValue = TCellData, TColu
private _value!: TValue;
private _units!: string;
private _tooltip!: string;

public readonly clickable: boolean = false;
public readonly isFirstColumn: boolean = false;
protected readonly columnConfigOptions: TColumnConfigOptions | undefined;
Expand Down
49 changes: 49 additions & 0 deletions projects/components/src/table/table-csv-downloader.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { createServiceFactory, mockProvider } from '@ngneat/spectator/jest';
import { FileDownloadService, NotificationService, TableCsvDownloaderService, TableRow } from '@hypertrace/components';
import { EMPTY, of } from 'rxjs';
import { fakeAsync, tick } from '@angular/core/testing';
import { TableColumnConfigExtended } from './table.service';
import { Dictionary } from '@hypertrace/common';

describe('TableCsvDownloaderService', () => {
const createService = createServiceFactory({
service: TableCsvDownloaderService,
providers: [
mockProvider(FileDownloadService, {
downloadAsCsv: jest.fn().mockReturnValue(EMPTY)
}),
mockProvider(NotificationService, {
createInfoToast: jest.fn()
})
]
});

test('execute download should behave as expected for no data', fakeAsync(() => {
const spectator = createService();
spectator.service.executeDownload(of({ rows: [], columnConfigs: [] }), 'table-id');

tick();
expect(spectator.inject(NotificationService).createInfoToast).toHaveBeenCalledWith('No data to download');
expect(spectator.inject(FileDownloadService).downloadAsCsv).not.toHaveBeenCalled();
}));

test('execute download should behave as expected when data is present', fakeAsync(() => {
const spectator = createService();
spectator.service.executeDownload(
of({
rows: [{ key1: 'value1' }] as TableRow[],
columnConfigs: [
{
id: 'key1',
name: 'key1',
csvGenerator: { generateSafeCsv: (cellData: Dictionary<string>, _row): Dictionary<string> => cellData }
}
] as TableColumnConfigExtended[]
}),
'table-id'
);
tick();

expect(spectator.inject(FileDownloadService).downloadAsCsv).toHaveBeenCalled();
}));
});
72 changes: 72 additions & 0 deletions projects/components/src/table/table-csv-downloader.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { EMPTY, Observable, of, Subject } from 'rxjs';
import { Injectable } from '@angular/core';
import { TableColumnConfigExtended } from './table.service';
import { TableRow } from './table-api';
import { FileDownloadService } from '../download-file/service/file-download.service';
import { map, switchMap } from 'rxjs/operators';
import { isEmpty, isNil, isString } from 'lodash-es';
import { Dictionary } from '@hypertrace/common';
import { NotificationService } from '../notification/notification.service';

@Injectable({ providedIn: 'root' })
export class TableCsvDownloaderService {
private readonly csvDownloadRequestSubject: Subject<string> = new Subject<string>();
public csvDownloadRequest$: Observable<string> = this.csvDownloadRequestSubject.asObservable();

public constructor(
private readonly fileDownloadService: FileDownloadService,
private readonly notificationService: NotificationService
) {}
public triggerDownload(tableId: string): void {
this.csvDownloadRequestSubject.next(tableId);
}

public executeDownload(
dataAndConfigs$: Observable<{ rows: TableRow[]; columnConfigs: TableColumnConfigExtended[] }>,
id?: string
): void {
dataAndConfigs$
.pipe(
map(({ rows, columnConfigs }) => {
const csvGeneratorMap = new Map(
columnConfigs.filter(column => !isNil(column.csvGenerator)).map(column => [column.id, column.csvGenerator])
);

return rows
.map(row => {
let rowValue: Dictionary<string | undefined> = {};
Array.from(csvGeneratorMap.keys()).forEach(columnKey => {
const value = row[columnKey];
const csvGenerator = csvGeneratorMap.get(columnKey)!; // Safe to assert here since we are processing columns with valid csv generators only

const csvContent = csvGenerator.generateSafeCsv(value, row);

if (!isNil(csvContent)) {
if (isString(csvContent)) {
rowValue[columnKey] = csvContent;
} else {
rowValue = { ...rowValue, ...csvContent };
}
}
});

return rowValue;
})
.filter(row => !isEmpty(row));
}),
switchMap((content: Dictionary<string | undefined>[]) => {
if (isEmpty(content)) {
this.notificationService.createInfoToast('No data to download');

return EMPTY;
}

return this.fileDownloadService.downloadAsCsv({
fileName: `table-data-${id}-${new Date().toISOString()}.csv`,
dataSource: of(content)
});
})
)
.subscribe();
}
}
Loading

0 comments on commit 101357c

Please sign in to comment.