Skip to content

Commit f307847

Browse files
authored
Merge pull request #279 from netgrif/NAE-2100
NAE-2100 - Case view export button as NAE feature
2 parents d1c14ac + c4aafc2 commit f307847

File tree

15 files changed

+264
-2
lines changed

15 files changed

+264
-2
lines changed

projects/netgrif-components-core/src/assets/i18n/de.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,5 +513,8 @@
513513
"previousPage": "Vorherige Seite",
514514
"pageOne": "Seite 1 von 1",
515515
"pageAmount": "Seite {{page}} von {{amountPages}}"
516+
},
517+
"export": {
518+
"errorExportDownload": "Datei konnte nicht heruntergeladen werden!"
516519
}
517520
}

projects/netgrif-components-core/src/assets/i18n/en.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,5 +514,8 @@
514514
"previousPage": "Previous page",
515515
"pageOne": "Page 1 of 1",
516516
"pageAmount": "Page {{page}} of {{amountPages}}"
517+
},
518+
"export": {
519+
"errorExportDownload": "File failed to download!"
517520
}
518521
}

projects/netgrif-components-core/src/assets/i18n/sk.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,5 +513,8 @@
513513
"previousPage": "Predchádzajúca strana",
514514
"pageOne": "Strana 1 z 1",
515515
"pageAmount": "Strana {{page}} z {{amountPages}}"
516+
},
517+
"export": {
518+
"errorExportDownload": "File failed to download!"
516519
}
517520
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/* SERVICES */
2+
export * from './services/export.service';
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { TestBed } from '@angular/core/testing';
2+
import { ExportService } from './export.service';
3+
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
4+
import { ConfigurationService } from '../../configuration/configuration.service';
5+
import { TranslateService } from '@ngx-translate/core';
6+
import { Filter } from '../../filter/models/filter';
7+
import { HeaderColumn, HeaderColumnType } from '../../header/models/header-column';
8+
9+
describe('ExportService', () => {
10+
let service: ExportService;
11+
let httpMock: HttpTestingController;
12+
13+
const mockConfigService = {
14+
get: () => ({
15+
providers: {
16+
resources: [{ name: 'case', address: 'http://mock-api' }]
17+
}
18+
})
19+
};
20+
21+
const mockTranslateService = {
22+
instant: (key: string) => `translated-${key}`
23+
};
24+
25+
beforeEach(() => {
26+
TestBed.configureTestingModule({
27+
imports: [HttpClientTestingModule],
28+
providers: [
29+
ExportService,
30+
{ provide: ConfigurationService, useValue: mockConfigService },
31+
{ provide: TranslateService, useValue: mockTranslateService }
32+
]
33+
});
34+
35+
service = TestBed.inject(ExportService);
36+
httpMock = TestBed.inject(HttpTestingController);
37+
});
38+
39+
afterEach(() => {
40+
httpMock.verify();
41+
});
42+
43+
it('should be created', () => {
44+
expect(service).toBeTruthy();
45+
});
46+
47+
describe('getResourceAddress()', () => {
48+
it('should return the address from an array', () => {
49+
const result = service.getResourceAddress('case', [{ name: 'case', address: 'http://test' }]);
50+
expect(result).toBe('http://test');
51+
});
52+
53+
it('should return the address from a single object', () => {
54+
const result = service.getResourceAddress('case', { name: 'case', address: 'http://test' });
55+
expect(result).toBe('http://test');
56+
});
57+
58+
it('should return an empty string if not found', () => {
59+
const result = service.getResourceAddress('other', [{ name: 'case', address: 'http://test' }]);
60+
expect(result).toBe('');
61+
});
62+
});
63+
64+
describe('downloadExcelFromCurrentSelection()', () => {
65+
it('should return true and trigger file download on valid response', (done) => {
66+
spyOn(document.body, 'appendChild');
67+
spyOn(document.body, 'removeChild');
68+
69+
const mockFilter: Filter = {
70+
getRequestBody: () => ({ some: 'query' })
71+
} as any;
72+
73+
const headers: HeaderColumn[] = [
74+
new HeaderColumn(HeaderColumnType.IMMEDIATE, 'name', 'Name', 'string', true, 'net-id'),
75+
new HeaderColumn(HeaderColumnType.META, 'date', 'Date', 'date')
76+
];
77+
78+
service.downloadExcelFromCurrentSelection(mockFilter, headers).subscribe((result) => {
79+
expect(result).toBeTrue();
80+
done();
81+
});
82+
83+
const req = httpMock.expectOne('http://mock-api/export/filteredCases');
84+
expect(req.request.method).toBe('POST');
85+
req.flush(new ArrayBuffer(10), {
86+
headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }
87+
});
88+
});
89+
90+
it('should return false when response body is missing', (done) => {
91+
const mockFilter: Filter = {
92+
getRequestBody: () => ({ some: 'query' })
93+
} as any;
94+
95+
service.downloadExcelFromCurrentSelection(mockFilter, []).subscribe((result) => {
96+
expect(result).toBeFalse();
97+
done();
98+
});
99+
100+
const req = httpMock.expectOne('http://mock-api/export/filteredCases');
101+
req.flush(null, { headers: { 'Content-Type': 'application/octet-stream' } });
102+
});
103+
});
104+
});
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import {Injectable} from '@angular/core';
2+
import {AbstractResourceProvider} from '../../resources/resource-provider.service';
3+
import {ConfigurationService} from '../../configuration/configuration.service';
4+
import {Filter} from '../../filter/models/filter';
5+
import {HeaderColumn, HeaderColumnType} from '../../header/models/header-column';
6+
import {MergedFilter} from '../../filter/models/merged-filter';
7+
import {MergeOperator} from '../../filter/models/merge-operator';
8+
import {HttpClient} from '@angular/common/http';
9+
import {TranslateService} from '@ngx-translate/core';
10+
import {switchMap} from 'rxjs/operators';
11+
import {Observable, of} from 'rxjs';
12+
13+
@Injectable({
14+
providedIn: 'root'
15+
})
16+
export class ExportService {
17+
18+
protected readonly SERVER_URL: string;
19+
20+
constructor(protected _httpClient: HttpClient,
21+
protected _translate: TranslateService,
22+
protected _configService: ConfigurationService) {
23+
this.SERVER_URL = this.getResourceAddress('case', this._configService.get().providers.resources);
24+
}
25+
26+
public downloadExcelFromCurrentSelection(activeFilter: Filter, currentHeaders: Array<HeaderColumn>): Observable<boolean> {
27+
const mergeOperation = activeFilter instanceof MergedFilter ? (activeFilter as any)._operator : MergeOperator.AND;
28+
29+
return this._httpClient.post(AbstractResourceProvider.sanitizeUrl(`/export/filteredCases`, this.SERVER_URL), {
30+
query: activeFilter.getRequestBody(),
31+
selectedDataFieldNames: currentHeaders.filter(header => header).map(header =>
32+
header.type === HeaderColumnType.IMMEDIATE ? header.title : this._translate.instant(header.title)),
33+
selectedDataFieldIds: currentHeaders.filter(header => header).map(
34+
header => header.type === HeaderColumnType.IMMEDIATE ? header.fieldIdentifier : (header.fieldIdentifier === 'mongoId' ? `meta-stringId` : `meta-${header.fieldIdentifier}`)),
35+
isIntersection: mergeOperation === MergeOperator.AND
36+
}, {
37+
responseType: 'arraybuffer', observe: 'response'
38+
}).pipe(switchMap((response: any) => {
39+
if (response && response.body) {
40+
const contentType = response.headers.get('Content-Type');
41+
const linkElement = document.createElement('a');
42+
const blob = new Blob([response.body], {type: contentType});
43+
const urlBlob = window.URL.createObjectURL(blob);
44+
linkElement.setAttribute('href', urlBlob);
45+
linkElement.setAttribute('download', 'export.xlsx');
46+
document.body.appendChild(linkElement);
47+
linkElement.click();
48+
document.body.removeChild(linkElement);
49+
return of(true);
50+
} else {
51+
return of(false);
52+
}
53+
}));
54+
}
55+
56+
public getResourceAddress(name: string, resourcesArray: any): string {
57+
let URL = '';
58+
if (resourcesArray instanceof Array) {
59+
resourcesArray.forEach(resource => {
60+
if (resource.name === name) {
61+
URL = resource.address;
62+
}
63+
});
64+
} else {
65+
if (resourcesArray.name === name) {
66+
URL = resourcesArray.address;
67+
}
68+
}
69+
return URL;
70+
}
71+
}

projects/netgrif-components-core/src/lib/navigation/model/group-navigation-constants.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,11 @@ export enum GroupNavigationConstants {
9595
* */
9696
ITEM_FIELD_ID_CASE_DEFAULT_HEADERS_MODE = 'case_headers_default_mode',
9797

98+
/**
99+
* Boolean field, that is true if table mode can be applied in case view
100+
* */
101+
ITEM_FIELD_ID_CASE_ALLOW_EXPORT = 'case_allow_export',
102+
98103
/**
99104
* Boolean field, that is true to make mode menu in case view visible
100105
* */

projects/netgrif-components-core/src/public-api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,4 @@ export * from './lib/impersonation/public-api';
4848
export * from './lib/registry/public-api';
4949
export * from './lib/actions/public-api';
5050
export * from './lib/providers/public-api';
51+
export * from './lib/export/public-api'

projects/netgrif-components/src/lib/navigation/group-navigation-component-resolver/default-components/default-tab-view/default-tab-view.component.spec.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,10 @@ describe('DefaultTabViewComponent', () => {
103103
GroupNavigationConstants.ITEM_FIELD_ID_CASE_DEFAULT_HEADERS,
104104
'','', {visible: true}
105105
),
106+
new BooleanField(
107+
GroupNavigationConstants.ITEM_FIELD_ID_CASE_ALLOW_EXPORT,
108+
'',true,{visible: true}
109+
),
106110
new EnumerationField(
107111
GroupNavigationConstants.ITEM_FIELD_ID_TASK_VIEW_SEARCH_TYPE,
108112
'',"fulltext", [],{visible: true}

projects/netgrif-components/src/lib/navigation/group-navigation-component-resolver/default-components/default-tab-view/default-tab-view.component.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ export class DefaultTabViewComponent {
8383
const caseViewHeadersMode = extractFieldValueFromData<string[]>(this._navigationItemTaskData, GroupNavigationConstants.ITEM_FIELD_ID_CASE_HEADERS_MODE);
8484
const caseViewAllowTableMode = extractFieldValueFromData<boolean>(this._navigationItemTaskData, GroupNavigationConstants.ITEM_FIELD_ID_CASE_ALLOW_TABLE_MODE);
8585
const caseViewDefaultHeadersMode = extractFieldValueFromData<string[]>(this._navigationItemTaskData, GroupNavigationConstants.ITEM_FIELD_ID_CASE_DEFAULT_HEADERS_MODE);
86+
const caseViewAllowExport = extractFieldValueFromData<boolean[]>(this._navigationItemTaskData, GroupNavigationConstants.ITEM_FIELD_ID_CASE_ALLOW_EXPORT);
8687

8788
const taskSearchType = extractSearchTypeFromData(this._navigationItemTaskData, GroupNavigationConstants.ITEM_FIELD_ID_TASK_VIEW_SEARCH_TYPE);
8889
const taskShowMoreMenu = extractFieldValueFromData<boolean>(this._navigationItemTaskData, GroupNavigationConstants.ITEM_FIELD_ID_TASK_SHOW_MORE_MENU);
@@ -116,6 +117,7 @@ export class DefaultTabViewComponent {
116117
caseViewHeadersMode: caseViewHeadersMode,
117118
caseViewAllowTableMode: caseViewAllowTableMode,
118119
caseViewDefaultHeadersMode: caseViewDefaultHeadersMode,
120+
caseViewAllowExport: caseViewAllowExport,
119121

120122
taskViewSearchTypeConfiguration: taskSearchTypeConfig,
121123
taskViewShowMoreMenu: taskShowMoreMenu,

0 commit comments

Comments
 (0)