diff --git a/projects/netgrif-components-core/src/assets/i18n/de.json b/projects/netgrif-components-core/src/assets/i18n/de.json index a8f7aa7ba..17e13787d 100644 --- a/projects/netgrif-components-core/src/assets/i18n/de.json +++ b/projects/netgrif-components-core/src/assets/i18n/de.json @@ -513,5 +513,8 @@ "previousPage": "Vorherige Seite", "pageOne": "Seite 1 von 1", "pageAmount": "Seite {{page}} von {{amountPages}}" + }, + "export": { + "errorExportDownload": "Datei konnte nicht heruntergeladen werden!" } } diff --git a/projects/netgrif-components-core/src/assets/i18n/en.json b/projects/netgrif-components-core/src/assets/i18n/en.json index d2bbf05e8..81b2ec9c3 100644 --- a/projects/netgrif-components-core/src/assets/i18n/en.json +++ b/projects/netgrif-components-core/src/assets/i18n/en.json @@ -514,5 +514,8 @@ "previousPage": "Previous page", "pageOne": "Page 1 of 1", "pageAmount": "Page {{page}} of {{amountPages}}" + }, + "export": { + "errorExportDownload": "File failed to download!" } } diff --git a/projects/netgrif-components-core/src/assets/i18n/sk.json b/projects/netgrif-components-core/src/assets/i18n/sk.json index e47da7ff9..de5e606d5 100644 --- a/projects/netgrif-components-core/src/assets/i18n/sk.json +++ b/projects/netgrif-components-core/src/assets/i18n/sk.json @@ -513,5 +513,8 @@ "previousPage": "Predchádzajúca strana", "pageOne": "Strana 1 z 1", "pageAmount": "Strana {{page}} z {{amountPages}}" + }, + "export": { + "errorExportDownload": "File failed to download!" } } diff --git a/projects/netgrif-components-core/src/lib/export/public-api.ts b/projects/netgrif-components-core/src/lib/export/public-api.ts new file mode 100644 index 000000000..b24160731 --- /dev/null +++ b/projects/netgrif-components-core/src/lib/export/public-api.ts @@ -0,0 +1,2 @@ +/* SERVICES */ +export * from './services/export.service'; diff --git a/projects/netgrif-components-core/src/lib/export/services/export.service.spec.ts b/projects/netgrif-components-core/src/lib/export/services/export.service.spec.ts new file mode 100644 index 000000000..57cf05a29 --- /dev/null +++ b/projects/netgrif-components-core/src/lib/export/services/export.service.spec.ts @@ -0,0 +1,104 @@ +import { TestBed } from '@angular/core/testing'; +import { ExportService } from './export.service'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { ConfigurationService } from '../../configuration/configuration.service'; +import { TranslateService } from '@ngx-translate/core'; +import { Filter } from '../../filter/models/filter'; +import { HeaderColumn, HeaderColumnType } from '../../header/models/header-column'; + +describe('ExportService', () => { + let service: ExportService; + let httpMock: HttpTestingController; + + const mockConfigService = { + get: () => ({ + providers: { + resources: [{ name: 'case', address: 'http://mock-api' }] + } + }) + }; + + const mockTranslateService = { + instant: (key: string) => `translated-${key}` + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + ExportService, + { provide: ConfigurationService, useValue: mockConfigService }, + { provide: TranslateService, useValue: mockTranslateService } + ] + }); + + service = TestBed.inject(ExportService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('getResourceAddress()', () => { + it('should return the address from an array', () => { + const result = service.getResourceAddress('case', [{ name: 'case', address: 'http://test' }]); + expect(result).toBe('http://test'); + }); + + it('should return the address from a single object', () => { + const result = service.getResourceAddress('case', { name: 'case', address: 'http://test' }); + expect(result).toBe('http://test'); + }); + + it('should return an empty string if not found', () => { + const result = service.getResourceAddress('other', [{ name: 'case', address: 'http://test' }]); + expect(result).toBe(''); + }); + }); + + describe('downloadExcelFromCurrentSelection()', () => { + it('should return true and trigger file download on valid response', (done) => { + spyOn(document.body, 'appendChild'); + spyOn(document.body, 'removeChild'); + + const mockFilter: Filter = { + getRequestBody: () => ({ some: 'query' }) + } as any; + + const headers: HeaderColumn[] = [ + new HeaderColumn(HeaderColumnType.IMMEDIATE, 'name', 'Name', 'string', true, 'net-id'), + new HeaderColumn(HeaderColumnType.META, 'date', 'Date', 'date') + ]; + + service.downloadExcelFromCurrentSelection(mockFilter, headers).subscribe((result) => { + expect(result).toBeTrue(); + done(); + }); + + const req = httpMock.expectOne('http://mock-api/export/filteredCases'); + expect(req.request.method).toBe('POST'); + req.flush(new ArrayBuffer(10), { + headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' } + }); + }); + + it('should return false when response body is missing', (done) => { + const mockFilter: Filter = { + getRequestBody: () => ({ some: 'query' }) + } as any; + + service.downloadExcelFromCurrentSelection(mockFilter, []).subscribe((result) => { + expect(result).toBeFalse(); + done(); + }); + + const req = httpMock.expectOne('http://mock-api/export/filteredCases'); + req.flush(null, { headers: { 'Content-Type': 'application/octet-stream' } }); + }); + }); +}); diff --git a/projects/netgrif-components-core/src/lib/export/services/export.service.ts b/projects/netgrif-components-core/src/lib/export/services/export.service.ts new file mode 100644 index 000000000..ecb2b8e0d --- /dev/null +++ b/projects/netgrif-components-core/src/lib/export/services/export.service.ts @@ -0,0 +1,71 @@ +import {Injectable} from '@angular/core'; +import {AbstractResourceProvider} from '../../resources/resource-provider.service'; +import {ConfigurationService} from '../../configuration/configuration.service'; +import {Filter} from '../../filter/models/filter'; +import {HeaderColumn, HeaderColumnType} from '../../header/models/header-column'; +import {MergedFilter} from '../../filter/models/merged-filter'; +import {MergeOperator} from '../../filter/models/merge-operator'; +import {HttpClient} from '@angular/common/http'; +import {TranslateService} from '@ngx-translate/core'; +import {switchMap} from 'rxjs/operators'; +import {Observable, of} from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class ExportService { + + protected readonly SERVER_URL: string; + + constructor(protected _httpClient: HttpClient, + protected _translate: TranslateService, + protected _configService: ConfigurationService) { + this.SERVER_URL = this.getResourceAddress('case', this._configService.get().providers.resources); + } + + public downloadExcelFromCurrentSelection(activeFilter: Filter, currentHeaders: Array): Observable { + const mergeOperation = activeFilter instanceof MergedFilter ? (activeFilter as any)._operator : MergeOperator.AND; + + return this._httpClient.post(AbstractResourceProvider.sanitizeUrl(`/export/filteredCases`, this.SERVER_URL), { + query: activeFilter.getRequestBody(), + selectedDataFieldNames: currentHeaders.filter(header => header).map(header => + header.type === HeaderColumnType.IMMEDIATE ? header.title : this._translate.instant(header.title)), + selectedDataFieldIds: currentHeaders.filter(header => header).map( + header => header.type === HeaderColumnType.IMMEDIATE ? header.fieldIdentifier : (header.fieldIdentifier === 'mongoId' ? `meta-stringId` : `meta-${header.fieldIdentifier}`)), + isIntersection: mergeOperation === MergeOperator.AND + }, { + responseType: 'arraybuffer', observe: 'response' + }).pipe(switchMap((response: any) => { + if (response && response.body) { + const contentType = response.headers.get('Content-Type'); + const linkElement = document.createElement('a'); + const blob = new Blob([response.body], {type: contentType}); + const urlBlob = window.URL.createObjectURL(blob); + linkElement.setAttribute('href', urlBlob); + linkElement.setAttribute('download', 'export.xlsx'); + document.body.appendChild(linkElement); + linkElement.click(); + document.body.removeChild(linkElement); + return of(true); + } else { + return of(false); + } + })); + } + + public getResourceAddress(name: string, resourcesArray: any): string { + let URL = ''; + if (resourcesArray instanceof Array) { + resourcesArray.forEach(resource => { + if (resource.name === name) { + URL = resource.address; + } + }); + } else { + if (resourcesArray.name === name) { + URL = resourcesArray.address; + } + } + return URL; + } +} diff --git a/projects/netgrif-components-core/src/lib/navigation/model/group-navigation-constants.ts b/projects/netgrif-components-core/src/lib/navigation/model/group-navigation-constants.ts index c3264b8b0..655270eb4 100644 --- a/projects/netgrif-components-core/src/lib/navigation/model/group-navigation-constants.ts +++ b/projects/netgrif-components-core/src/lib/navigation/model/group-navigation-constants.ts @@ -95,6 +95,11 @@ export enum GroupNavigationConstants { * */ ITEM_FIELD_ID_CASE_DEFAULT_HEADERS_MODE = 'case_headers_default_mode', + /** + * Boolean field, that is true if table mode can be applied in case view + * */ + ITEM_FIELD_ID_CASE_ALLOW_EXPORT = 'case_allow_export', + /** * Boolean field, that is true to make mode menu in case view visible * */ diff --git a/projects/netgrif-components-core/src/public-api.ts b/projects/netgrif-components-core/src/public-api.ts index 6ce3a9d10..a817bbb5a 100644 --- a/projects/netgrif-components-core/src/public-api.ts +++ b/projects/netgrif-components-core/src/public-api.ts @@ -48,3 +48,4 @@ export * from './lib/impersonation/public-api'; export * from './lib/registry/public-api'; export * from './lib/actions/public-api'; export * from './lib/providers/public-api'; +export * from './lib/export/public-api' diff --git a/projects/netgrif-components/src/lib/navigation/group-navigation-component-resolver/default-components/default-tab-view/default-tab-view.component.spec.ts b/projects/netgrif-components/src/lib/navigation/group-navigation-component-resolver/default-components/default-tab-view/default-tab-view.component.spec.ts index 0bbb46bf6..9fa79aefd 100644 --- a/projects/netgrif-components/src/lib/navigation/group-navigation-component-resolver/default-components/default-tab-view/default-tab-view.component.spec.ts +++ b/projects/netgrif-components/src/lib/navigation/group-navigation-component-resolver/default-components/default-tab-view/default-tab-view.component.spec.ts @@ -103,6 +103,10 @@ describe('DefaultTabViewComponent', () => { GroupNavigationConstants.ITEM_FIELD_ID_CASE_DEFAULT_HEADERS, '','', {visible: true} ), + new BooleanField( + GroupNavigationConstants.ITEM_FIELD_ID_CASE_ALLOW_EXPORT, + '',true,{visible: true} + ), new EnumerationField( GroupNavigationConstants.ITEM_FIELD_ID_TASK_VIEW_SEARCH_TYPE, '',"fulltext", [],{visible: true} diff --git a/projects/netgrif-components/src/lib/navigation/group-navigation-component-resolver/default-components/default-tab-view/default-tab-view.component.ts b/projects/netgrif-components/src/lib/navigation/group-navigation-component-resolver/default-components/default-tab-view/default-tab-view.component.ts index cd204dd9e..d3f4f5065 100644 --- a/projects/netgrif-components/src/lib/navigation/group-navigation-component-resolver/default-components/default-tab-view/default-tab-view.component.ts +++ b/projects/netgrif-components/src/lib/navigation/group-navigation-component-resolver/default-components/default-tab-view/default-tab-view.component.ts @@ -83,6 +83,7 @@ export class DefaultTabViewComponent { const caseViewHeadersMode = extractFieldValueFromData(this._navigationItemTaskData, GroupNavigationConstants.ITEM_FIELD_ID_CASE_HEADERS_MODE); const caseViewAllowTableMode = extractFieldValueFromData(this._navigationItemTaskData, GroupNavigationConstants.ITEM_FIELD_ID_CASE_ALLOW_TABLE_MODE); const caseViewDefaultHeadersMode = extractFieldValueFromData(this._navigationItemTaskData, GroupNavigationConstants.ITEM_FIELD_ID_CASE_DEFAULT_HEADERS_MODE); + const caseViewAllowExport = extractFieldValueFromData(this._navigationItemTaskData, GroupNavigationConstants.ITEM_FIELD_ID_CASE_ALLOW_EXPORT); const taskSearchType = extractSearchTypeFromData(this._navigationItemTaskData, GroupNavigationConstants.ITEM_FIELD_ID_TASK_VIEW_SEARCH_TYPE); const taskShowMoreMenu = extractFieldValueFromData(this._navigationItemTaskData, GroupNavigationConstants.ITEM_FIELD_ID_TASK_SHOW_MORE_MENU); @@ -116,6 +117,7 @@ export class DefaultTabViewComponent { caseViewHeadersMode: caseViewHeadersMode, caseViewAllowTableMode: caseViewAllowTableMode, caseViewDefaultHeadersMode: caseViewDefaultHeadersMode, + caseViewAllowExport: caseViewAllowExport, taskViewSearchTypeConfiguration: taskSearchTypeConfig, taskViewShowMoreMenu: taskShowMoreMenu, diff --git a/projects/netgrif-components/src/lib/navigation/group-navigation-component-resolver/default-components/default-tabbed-case-view/default-tabbed-case-view.component.html b/projects/netgrif-components/src/lib/navigation/group-navigation-component-resolver/default-components/default-tabbed-case-view/default-tabbed-case-view.component.html index e38f3e013..872351014 100644 --- a/projects/netgrif-components/src/lib/navigation/group-navigation-component-resolver/default-components/default-tabbed-case-view/default-tabbed-case-view.component.html +++ b/projects/netgrif-components/src/lib/navigation/group-navigation-component-resolver/default-components/default-tabbed-case-view/default-tabbed-case-view.component.html @@ -7,6 +7,13 @@ +
+ +
diff --git a/projects/netgrif-components/src/lib/navigation/group-navigation-component-resolver/default-components/default-tabbed-case-view/default-tabbed-case-view.component.scss b/projects/netgrif-components/src/lib/navigation/group-navigation-component-resolver/default-components/default-tabbed-case-view/default-tabbed-case-view.component.scss index f7fc8fe5a..a160bd435 100644 --- a/projects/netgrif-components/src/lib/navigation/group-navigation-component-resolver/default-components/default-tabbed-case-view/default-tabbed-case-view.component.scss +++ b/projects/netgrif-components/src/lib/navigation/group-navigation-component-resolver/default-components/default-tabbed-case-view/default-tabbed-case-view.component.scss @@ -4,6 +4,20 @@ overflow: auto } +.button-icon { + padding-right: 4px; + padding-bottom: 2px; +} + +.export-mat-mini-fab { + border-radius: 6px; + box-shadow: none; + height: 44px !important; + min-width: 44px; + margin-right: 8px; + margin-top: 2px; +} + .case-view-search-container { margin-top: 16px; margin-bottom: 2px; diff --git a/projects/netgrif-components/src/lib/navigation/group-navigation-component-resolver/default-components/default-tabbed-case-view/default-tabbed-case-view.component.spec.ts b/projects/netgrif-components/src/lib/navigation/group-navigation-component-resolver/default-components/default-tabbed-case-view/default-tabbed-case-view.component.spec.ts index 2e134e40b..fbbdc9998 100644 --- a/projects/netgrif-components/src/lib/navigation/group-navigation-component-resolver/default-components/default-tabbed-case-view/default-tabbed-case-view.component.spec.ts +++ b/projects/netgrif-components/src/lib/navigation/group-navigation-component-resolver/default-components/default-tabbed-case-view/default-tabbed-case-view.component.spec.ts @@ -53,6 +53,7 @@ describe('DefaultTabbedCaseViewComponent', () => { caseViewAllowTableMode: true, caseViewDefaultHeadersMode: HeaderMode.SORT, caseViewShowMoreMenu: true, + caseViewAllowExport: true, navigationItemTaskData: [{fields: []}, { fields: [ new FilterField( diff --git a/projects/netgrif-components/src/lib/navigation/group-navigation-component-resolver/default-components/default-tabbed-case-view/default-tabbed-case-view.component.ts b/projects/netgrif-components/src/lib/navigation/group-navigation-component-resolver/default-components/default-tabbed-case-view/default-tabbed-case-view.component.ts index 72e532efc..fa28f4e8e 100644 --- a/projects/netgrif-components/src/lib/navigation/group-navigation-component-resolver/default-components/default-tabbed-case-view/default-tabbed-case-view.component.ts +++ b/projects/netgrif-components/src/lib/navigation/group-navigation-component-resolver/default-components/default-tabbed-case-view/default-tabbed-case-view.component.ts @@ -1,4 +1,4 @@ -import {AfterViewInit, Component, Inject, ViewChild} from '@angular/core'; +import {AfterViewInit, Component, Inject, OnDestroy, ViewChild} from '@angular/core'; import { AbstractTabbedCaseViewComponent, AllowedNetsService, @@ -28,6 +28,10 @@ import { navigationItemCaseViewDefaultHeadersFactory, NAE_NAVIGATION_ITEM_TASK_DATA, OverflowService, + LoadingEmitter, + SnackBarService, + HeaderColumn, + ExportService } from '@netgrif/components-core'; import {HeaderComponent} from '../../../../header/header.component'; import { @@ -38,6 +42,8 @@ import { filterCaseTabbedDataFilterFactory, filterCaseTabbedDataSearchCategoriesFactory } from '../model/factory-methods'; +import {Subscription} from "rxjs"; +import {TranslateService} from "@ngx-translate/core"; @Component({ selector: 'nc-default-tabbed-case-view', @@ -71,7 +77,7 @@ import { } ] }) -export class DefaultTabbedCaseViewComponent extends AbstractTabbedCaseViewComponent implements AfterViewInit { +export class DefaultTabbedCaseViewComponent extends AbstractTabbedCaseViewComponent implements AfterViewInit, OnDestroy { @ViewChild('header') public caseHeaderComponent: HeaderComponent; @@ -84,8 +90,16 @@ export class DefaultTabbedCaseViewComponent extends AbstractTabbedCaseViewCompon headersMode: string[]; allowTableMode: boolean; defaultHeadersMode: HeaderMode; + allowExport: boolean; + loading$: LoadingEmitter; + private _currentHeaders: Array = []; + private _headersSub: Subscription; constructor(caseViewService: CaseViewService, + protected _exportService: ExportService, + protected _searchService: SearchService, + protected _snackbar: SnackBarService, + protected _translate: TranslateService, loggerService: LoggerService, viewIdService: ViewIdService, overflowService: OverflowService, @@ -101,6 +115,11 @@ export class DefaultTabbedCaseViewComponent extends AbstractTabbedCaseViewCompon this.headersMode = _injectedTabData.caseViewHeadersMode ? _injectedTabData.caseViewHeadersMode : []; this.allowTableMode = this._injectedTabData.caseViewAllowTableMode; this.defaultHeadersMode = this.resolveHeaderMode(_injectedTabData.caseViewDefaultHeadersMode); + this.allowExport = this._injectedTabData.caseViewAllowExport; + this.loading$ = new LoadingEmitter(); + this._headersSub = this.selectedHeaders$.subscribe(headers => { + this._currentHeaders = headers; + }); if (!this.allowTableMode) { const viewId = viewIdService.viewId; @@ -195,4 +214,26 @@ export class DefaultTabbedCaseViewComponent extends AbstractTabbedCaseViewCompon return undefined; } } + + isLoading(): boolean { + return this.loading$.isActive; + } + + export(): void { + if (this.loading$.isActive) { + return; + } + this.loading$.on(); + this._exportService.downloadExcelFromCurrentSelection(this._searchService.activeFilter, this._currentHeaders).subscribe(() => { + this.loading$.off(); + },error => { + this._loggerService.error('File download failed', error); + this._snackbar.openErrorSnackBar(this._translate.instant('export.errorExportDownload')); + this.loading$.off(); + }); + } + + ngOnDestroy(): void { + this.loading$.complete() + } } diff --git a/projects/netgrif-components/src/lib/navigation/group-navigation-component-resolver/default-components/model/injected-tabbed-case-view-data-with-navigation-item-task-data.ts b/projects/netgrif-components/src/lib/navigation/group-navigation-component-resolver/default-components/model/injected-tabbed-case-view-data-with-navigation-item-task-data.ts index 6b1ac0f19..355c5a492 100644 --- a/projects/netgrif-components/src/lib/navigation/group-navigation-component-resolver/default-components/model/injected-tabbed-case-view-data-with-navigation-item-task-data.ts +++ b/projects/netgrif-components/src/lib/navigation/group-navigation-component-resolver/default-components/model/injected-tabbed-case-view-data-with-navigation-item-task-data.ts @@ -21,6 +21,7 @@ export interface InjectedTabbedCaseViewDataWithNavigationItemTaskData extends In caseViewHeadersMode: string[]; caseViewAllowTableMode: boolean; caseViewDefaultHeadersMode: string; + caseViewAllowExport: boolean; taskViewSearchTypeConfiguration: SearchComponentConfiguration; taskViewShowMoreMenu: boolean;