From 51591dc9f2e8fb353c7acc9f977266e09d0e95e6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fran=C3=A7ois=20Agneray?= <francois.agneray@lam.fr>
Date: Fri, 4 Mar 2022 18:27:51 +0100
Subject: [PATCH] Downloading files component => WIP

---
 client/src/app/instance/instance.reducer.ts   |  7 ++-
 .../result/datatable-tab.component.html       |  3 +-
 .../result/datatable-tab.component.ts         |  1 +
 .../result/datatable.component.html           |  3 +-
 .../components/result/datatable.component.ts  |  1 +
 .../result/download-file-tab.component.html   | 11 ++++
 .../result/download-file-tab.component.scss   |  0
 .../result/download-file-tab.component.ts     | 25 ++++++++
 .../components/result/download.component.ts   | 49 ++++++++-------
 .../search/components/result/index.ts         |  2 +
 .../renderer/download-renderer.component.ts   | 23 +++-----
 .../search/containers/result.component.html   | 10 +++-
 .../search/containers/result.component.ts     | 15 ++++-
 .../store/actions/download-file.actions.ts    | 14 +++++
 .../instance/store/effects/detail.effects.ts  |  2 +-
 .../store/effects/download-file.effects.ts    | 59 +++++++++++++++++++
 .../src/app/instance/store/effects/index.ts   |  4 +-
 .../store/models/download-file.model.ts       | 14 +++++
 client/src/app/instance/store/models/index.ts |  3 +-
 .../store/reducers/download-file.reducer.ts   | 51 ++++++++++++++++
 .../store/selectors/download-file.selector.ts | 23 ++++++++
 .../store/services/download-file.service.ts   | 34 +++++++++++
 .../src/app/instance/store/services/index.ts  |  4 +-
 client/src/app/shared/shared.module.ts        |  3 +
 24 files changed, 309 insertions(+), 52 deletions(-)
 create mode 100644 client/src/app/instance/search/components/result/download-file-tab.component.html
 create mode 100644 client/src/app/instance/search/components/result/download-file-tab.component.scss
 create mode 100644 client/src/app/instance/search/components/result/download-file-tab.component.ts
 create mode 100644 client/src/app/instance/store/actions/download-file.actions.ts
 create mode 100644 client/src/app/instance/store/effects/download-file.effects.ts
 create mode 100644 client/src/app/instance/store/models/download-file.model.ts
 create mode 100644 client/src/app/instance/store/reducers/download-file.reducer.ts
 create mode 100644 client/src/app/instance/store/selectors/download-file.selector.ts
 create mode 100644 client/src/app/instance/store/services/download-file.service.ts

diff --git a/client/src/app/instance/instance.reducer.ts b/client/src/app/instance/instance.reducer.ts
index 062c160c..ed942d2b 100644
--- a/client/src/app/instance/instance.reducer.ts
+++ b/client/src/app/instance/instance.reducer.ts
@@ -16,6 +16,7 @@ import * as samp from './store/reducers/samp.reducer';
 import * as coneSearch from './store/reducers/cone-search.reducer';
 import * as detail from './store/reducers/detail.reducer';
 import * as svomJsonKw from './store/reducers/svom-json-kw.reducer';
+import * as downloadFile from './store/reducers/download-file.reducer';
 
 /**
  * Interface for instance state.
@@ -28,7 +29,8 @@ export interface State {
     samp: samp.State,
     coneSearch: coneSearch.State
     detail: detail.State,
-    svomJsonKw: svomJsonKw.State
+    svomJsonKw: svomJsonKw.State,
+    downloadFile: downloadFile.State
 }
 
 const reducers = {
@@ -37,7 +39,8 @@ const reducers = {
     samp: samp.sampReducer,
     coneSearch: coneSearch.coneSearchReducer,
     detail: detail.detailReducer,
-    svomJsonKw: svomJsonKw.svomJsonKwReducer
+    svomJsonKw: svomJsonKw.svomJsonKwReducer,
+    downloadFile: downloadFile.fileReducer
 };
 
 export const instanceReducer = combineReducers(reducers);
diff --git a/client/src/app/instance/search/components/result/datatable-tab.component.html b/client/src/app/instance/search/components/result/datatable-tab.component.html
index b0ad6b0c..c638c569 100644
--- a/client/src/app/instance/search/components/result/datatable-tab.component.html
+++ b/client/src/app/instance/search/components/result/datatable-tab.component.html
@@ -33,7 +33,8 @@
                 [selectedData]="selectedData"
                 (retrieveData)="retrieveData.emit($event)"
                 (addSelectedData)="addSelectedData.emit($event)"
-                (deleteSelectedData)="deleteSelectedData.emit($event)">
+                (deleteSelectedData)="deleteSelectedData.emit($event)"
+                (startsDownloadingFile)="startsDownloadingFile.emit($event)">
         </app-datatable>
     </accordion-group>
 </accordion>
diff --git a/client/src/app/instance/search/components/result/datatable-tab.component.ts b/client/src/app/instance/search/components/result/datatable-tab.component.ts
index c70564ba..4eee5ec4 100644
--- a/client/src/app/instance/search/components/result/datatable-tab.component.ts
+++ b/client/src/app/instance/search/components/result/datatable-tab.component.ts
@@ -40,4 +40,5 @@ export class DatatableTabComponent {
     @Output() addSelectedData: EventEmitter<number | string> = new EventEmitter();
     @Output() deleteSelectedData: EventEmitter<number | string> = new EventEmitter();
     @Output() broadcast: EventEmitter<string> = new EventEmitter();
+    @Output() startsDownloadingFile: EventEmitter<{url: string, filename: string}> = new EventEmitter();
 }
diff --git a/client/src/app/instance/search/components/result/datatable.component.html b/client/src/app/instance/search/components/result/datatable.component.html
index bbf61801..f4b0078f 100644
--- a/client/src/app/instance/search/components/result/datatable.component.html
+++ b/client/src/app/instance/search/components/result/datatable.component.html
@@ -62,7 +62,8 @@
                                 [value]="datum[attribute.label]"
                                 [datasetName]="dataset.name"
                                 [datasetPublic]="dataset.public"
-                                [config]="getRendererConfig(attribute)">
+                                [config]="getRendererConfig(attribute)"
+                                (startsDownloadingFile)="startsDownloadingFile.emit($event)">
                             </app-download-renderer>
                         </div>
                         <div *ngSwitchCase="'image'">
diff --git a/client/src/app/instance/search/components/result/datatable.component.ts b/client/src/app/instance/search/components/result/datatable.component.ts
index bab2e9c7..33191d25 100644
--- a/client/src/app/instance/search/components/result/datatable.component.ts
+++ b/client/src/app/instance/search/components/result/datatable.component.ts
@@ -44,6 +44,7 @@ export class DatatableComponent implements OnInit {
     @Output() retrieveData: EventEmitter<Pagination> = new EventEmitter();
     @Output() addSelectedData: EventEmitter<number | string> = new EventEmitter();
     @Output() deleteSelectedData: EventEmitter<number | string> = new EventEmitter();
+    @Output() startsDownloadingFile: EventEmitter<{url: string, filename: string}> = new EventEmitter();
 
     public page = 1;
     public nbItems = 10;
diff --git a/client/src/app/instance/search/components/result/download-file-tab.component.html b/client/src/app/instance/search/components/result/download-file-tab.component.html
new file mode 100644
index 00000000..2030ad73
--- /dev/null
+++ b/client/src/app/instance/search/components/result/download-file-tab.component.html
@@ -0,0 +1,11 @@
+<div class="jumbotron mb-4 py-4">
+    <div class="lead">
+        Files downloaded : 
+        <ul>
+            <li *ngFor="let downloadFile of downloadedFiles">
+                {{ downloadFile.name }} : 
+                <progressbar [value]="downloadFile.progress" type="warning" [striped]="false">{{ downloadFile.progress }}%</progressbar> 
+            </li>
+        </ul>
+    </div>
+</div>
\ No newline at end of file
diff --git a/client/src/app/instance/search/components/result/download-file-tab.component.scss b/client/src/app/instance/search/components/result/download-file-tab.component.scss
new file mode 100644
index 00000000..e69de29b
diff --git a/client/src/app/instance/search/components/result/download-file-tab.component.ts b/client/src/app/instance/search/components/result/download-file-tab.component.ts
new file mode 100644
index 00000000..cc8ad76c
--- /dev/null
+++ b/client/src/app/instance/search/components/result/download-file-tab.component.ts
@@ -0,0 +1,25 @@
+/**
+ * This file is part of Anis Client.
+ *
+ * @copyright Laboratoire d'Astrophysique de Marseille / CNRS
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+import { Component, Input } from '@angular/core';
+
+import { DownloadFile } from 'src/app/instance/store/models';
+
+/**
+ * @class
+ * @classdesc Search result reminder component.
+ */
+@Component({
+    selector: 'app-download-file-tab',
+    templateUrl: 'download-file-tab.component.html',
+    styleUrls: ['download-file-tab.component.scss']
+})
+export class DownloadFileTabComponent {
+    @Input() downloadedFiles: DownloadFile[];
+}
diff --git a/client/src/app/instance/search/components/result/download.component.ts b/client/src/app/instance/search/components/result/download.component.ts
index 6094323b..4b3c199a 100644
--- a/client/src/app/instance/search/components/result/download.component.ts
+++ b/client/src/app/instance/search/components/result/download.component.ts
@@ -8,7 +8,7 @@
  */
 
 import { Component, EventEmitter, Input, OnDestroy, Output } from '@angular/core';
-import { HttpClient } from '@angular/common/http';
+import { HttpClient, HttpRequest, HttpEventType } from '@angular/common/http';
 import { interval, Subscription} from 'rxjs';
 
 import { Criterion, ConeSearch, criterionToString } from '../../../store/models';
@@ -33,6 +33,7 @@ export class DownloadComponent implements OnDestroy {
     @Input() dataLength: number;
     @Input() sampRegistered: boolean;
     @Output() broadcast: EventEmitter<string> = new EventEmitter();
+    @Output() startsDownloadingFile: EventEmitter<{url: string, filename: string}> = new EventEmitter();
 
     archiveName = '';
     archiveInProgress = false;
@@ -93,19 +94,15 @@ export class DownloadComponent implements OnDestroy {
     }
 
     /**
-     * Returns URL to download archive.
-     *
-     * @return boolean
+     * Allows to download file.
      */
-    createFilesArchiveUrl(): string {
-        let query: string = `${getHost(this.appConfig.apiUrl)}/archive/${this.datasetSelected}?a=${this.outputList.join(';')}`;
-        if (this.criteriaList.length > 0) {
-            query += `&c=${this.criteriaList.map(criterion => criterionToString(criterion)).join(';')}`;
-        }
-        if (this.coneSearch) {
-            query += `&cs=${this.coneSearch.ra}:${this.coneSearch.dec}:${this.coneSearch.radius}`;
-        }
-        return query;
+     click(event, href, extension): void {
+        event.preventDefault();
+
+        const url = href;
+        const filename = `${this.datasetSelected}.${extension}`;
+
+        this.startsDownloadingFile.emit({ url, filename })
     }
 
     /**
@@ -113,24 +110,24 @@ export class DownloadComponent implements OnDestroy {
      *
      * @fires EventEmitter<string>
      */
-    broadcastVotable(): void {
+     broadcastVotable(): void {
         this.broadcast.emit(this.getUrl('votable'));
     }
 
     /**
-     * Allows to download file.
+     * Returns URL to download archive.
+     *
+     * @return boolean
      */
-    click(event, href, extension): void {
-        event.preventDefault();
-
-        this.http.get(href, {responseType: "blob"}).subscribe(
-            data => {
-                let downloadLink = document.createElement('a');
-                downloadLink.href = window.URL.createObjectURL(data);
-                downloadLink.setAttribute('download', `${this.datasetSelected}.${extension}`);
-                downloadLink.click();
-            }
-        );
+    createFilesArchiveUrl(): string {
+        let query: string = `${getHost(this.appConfig.apiUrl)}/archive/${this.datasetSelected}?a=${this.outputList.join(';')}`;
+        if (this.criteriaList.length > 0) {
+            query += `&c=${this.criteriaList.map(criterion => criterionToString(criterion)).join(';')}`;
+        }
+        if (this.coneSearch) {
+            query += `&cs=${this.coneSearch.ra}:${this.coneSearch.dec}:${this.coneSearch.radius}`;
+        }
+        return query;
     }
 
     getArchiveUrl() {
diff --git a/client/src/app/instance/search/components/result/index.ts b/client/src/app/instance/search/components/result/index.ts
index fffbcf4e..4fa8d96a 100644
--- a/client/src/app/instance/search/components/result/index.ts
+++ b/client/src/app/instance/search/components/result/index.ts
@@ -5,6 +5,7 @@ import { SampComponent } from './samp.component';
 import { UrlDisplayComponent } from './url-display.component';
 import { DatatableComponent } from './datatable.component';
 import { DatatableActionsComponent } from './datatable-actions.component';
+import { DownloadFileTabComponent } from './download-file-tab.component';
 import { rendererComponents } from './renderer';
 
 export const resultComponents = [
@@ -15,5 +16,6 @@ export const resultComponents = [
     UrlDisplayComponent,
     DatatableComponent,
     DatatableActionsComponent,
+    DownloadFileTabComponent,
     rendererComponents
 ];
diff --git a/client/src/app/instance/search/components/result/renderer/download-renderer.component.ts b/client/src/app/instance/search/components/result/renderer/download-renderer.component.ts
index db6f92c8..50c55181 100644
--- a/client/src/app/instance/search/components/result/renderer/download-renderer.component.ts
+++ b/client/src/app/instance/search/components/result/renderer/download-renderer.component.ts
@@ -7,8 +7,7 @@
  * file that was distributed with this source code.
  */
 
-import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
-import { HttpClient } from '@angular/common/http';
+import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core';
 
 import { DownloadRendererConfig } from 'src/app/metamodel/models/renderers/download-renderer-config.model';
 import { getHost } from 'src/app/shared/utils';
@@ -28,8 +27,9 @@ export class DownloadRendererComponent {
     @Input() datasetName: string;
     @Input() datasetPublic: boolean;
     @Input() config: DownloadRendererConfig;
+    @Output() startsDownloadingFile: EventEmitter<{url: string, filename: string}> = new EventEmitter();
 
-    constructor(private appConfig: AppConfigService, private http: HttpClient) { }
+    constructor(private appConfig: AppConfigService) { }
 
     /**
      * Returns link href.
@@ -50,21 +50,14 @@ export class DownloadRendererComponent {
     }
 
     /**
-     * Downloads file on click.
+     * Starts downloading file on click.
      */
     click(event): void {
         event.preventDefault();
+        
+        const url = this.getHref();
+        const filename = url.substring(url.lastIndexOf('/') + 1);
 
-        const href = this.getHref();
-        this.http.get(href, { responseType: "blob" }).subscribe(
-            data => {
-                const filename = href.substring(href.lastIndexOf('/') + 1);
-
-                let downloadLink = document.createElement('a');
-                downloadLink.href = window.URL.createObjectURL(data);
-                downloadLink.setAttribute('download', filename);
-                downloadLink.click();
-            }
-        );
+        this.startsDownloadingFile.emit({ url, filename })
     }
 }
diff --git a/client/src/app/instance/search/containers/result.component.html b/client/src/app/instance/search/containers/result.component.html
index 6ab41d2c..7775c355 100644
--- a/client/src/app/instance/search/containers/result.component.html
+++ b/client/src/app/instance/search/containers/result.component.html
@@ -25,6 +25,10 @@
                     selected with <span class="font-weight-bold">{{ dataLength | async }}</span> objects found.
                 </div>
             </div>
+            <app-download-file-tab
+                *ngIf="(downloadedFiles | async).length > 0"
+                [downloadedFiles]="downloadedFiles | async">
+            </app-download-file-tab>
             <app-download
                 [datasetSelected]="datasetSelected | async"
                 [datasetList]="datasetList | async"
@@ -33,7 +37,8 @@
                 [coneSearch]="coneSearch | async"
                 [dataLength]="dataLength | async"
                 [sampRegistered]="sampRegistered | async"
-                (broadcast)="broadcastVotable($event)">
+                (broadcast)="broadcastVotable($event)"
+                (startsDownloadingFile)="startsDownloadingFile($event)">
             </app-download>
             <app-reminder
                 [datasetSelected]="datasetSelected | async"
@@ -77,7 +82,8 @@
                 (retrieveData)="retrieveData($event)"
                 (addSelectedData)="addSearchData($event)"
                 (deleteSelectedData)="deleteSearchData($event)"
-                (broadcast)="broadcastVotable($event)">
+                (broadcast)="broadcastVotable($event)"
+                (startsDownloadingFile)="startsDownloadingFile($event)">
             </app-datatable-tab>
         </ng-container>
     </div>
diff --git a/client/src/app/instance/search/containers/result.component.ts b/client/src/app/instance/search/containers/result.component.ts
index 599e5cb5..90fc2319 100644
--- a/client/src/app/instance/search/containers/result.component.ts
+++ b/client/src/app/instance/search/containers/result.component.ts
@@ -13,13 +13,15 @@ import { Store } from '@ngrx/store';
 import { Observable, Subscription } from 'rxjs';
 
 import { AbstractSearchComponent } from './abstract-search.component';
-import { Pagination } from '../../store/models';
+import { Pagination, DownloadFile } from '../../store/models';
 import { Instance } from 'src/app/metamodel/models';
 import * as instanceSelector from 'src/app/metamodel/selectors/instance.selector';
 import * as searchActions from '../../store/actions/search.actions';
 import * as searchSelector from '../../store/selectors/search.selector';
 import * as sampActions from '../../store/actions/samp.actions';
 import * as sampSelector from '../../store/selectors/samp.selector';
+import * as downloadFileActions from '../../store/actions/download-file.actions';
+import * as downloadFileSelector from '../../store/selectors/download-file.selector';
 
 /**
  * @class
@@ -42,6 +44,7 @@ export class ResultComponent extends AbstractSearchComponent {
     public dataIsLoaded: Observable<boolean>;
     public selectedData: Observable<any>;
     public sampRegistered: Observable<boolean>;
+    public downloadedFiles: Observable<DownloadFile[]>;
     public pristineSubscription: Subscription;
 
     constructor(protected store: Store<{ }>) {
@@ -55,6 +58,7 @@ export class ResultComponent extends AbstractSearchComponent {
         this.dataIsLoaded = this.store.select(searchSelector.selectDataIsLoaded);
         this.selectedData = this.store.select(searchSelector.selectSelectedData);
         this.sampRegistered = this.store.select(sampSelector.selectRegistered);
+        this.downloadedFiles = this.store.select(downloadFileSelector.selectDownloadedFiles);
     }
 
     ngOnInit(): void {
@@ -120,6 +124,15 @@ export class ResultComponent extends AbstractSearchComponent {
         this.store.dispatch(searchActions.deleteSelectedData({ id }));
     }
 
+    /**
+     * Dispatches action to starts downloading file.
+     *
+     * @param  {url: string, filename: string} download - Info about file to download
+     */
+    startsDownloadingFile(download: {url: string, filename: string}): void {
+        this.store.dispatch(downloadFileActions.startsDownloadingFile(download));
+    }
+
     /**
      * Dispatches action to destroy search results.
      */
diff --git a/client/src/app/instance/store/actions/download-file.actions.ts b/client/src/app/instance/store/actions/download-file.actions.ts
new file mode 100644
index 00000000..f5ff418d
--- /dev/null
+++ b/client/src/app/instance/store/actions/download-file.actions.ts
@@ -0,0 +1,14 @@
+/**
+ * This file is part of Anis Client.
+ *
+ * @copyright Laboratoire d'Astrophysique de Marseille / CNRS
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+import { createAction, props } from '@ngrx/store';
+
+export const startsDownloadingFile = createAction('[File] Starts Downloading File', props<{ url: string, filename: string }>());
+export const updateDownloadProgress = createAction('[File] Update Download Progress', props<{ progress: number, filename: string }>());
+export const startsArchiveFilesCreation = createAction('[File] Starts Archive Files Creation');
diff --git a/client/src/app/instance/store/effects/detail.effects.ts b/client/src/app/instance/store/effects/detail.effects.ts
index 941f318d..b1f2aa5f 100644
--- a/client/src/app/instance/store/effects/detail.effects.ts
+++ b/client/src/app/instance/store/effects/detail.effects.ts
@@ -10,7 +10,7 @@
 import { Injectable } from '@angular/core';
 
 import { Actions, createEffect, ofType, concatLatestFrom } from '@ngrx/effects';
-import { Store  } from '@ngrx/store';
+import { Store } from '@ngrx/store';
 import { of } from 'rxjs';
 import { map, tap, mergeMap, catchError } from 'rxjs/operators';
 import { ToastrService } from 'ngx-toastr';
diff --git a/client/src/app/instance/store/effects/download-file.effects.ts b/client/src/app/instance/store/effects/download-file.effects.ts
new file mode 100644
index 00000000..21091c77
--- /dev/null
+++ b/client/src/app/instance/store/effects/download-file.effects.ts
@@ -0,0 +1,59 @@
+/**
+ * This file is part of Anis Client.
+ *
+ * @copyright Laboratoire d'Astrophysique de Marseille / CNRS
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+import { Injectable } from '@angular/core';
+import { HttpEventType } from '@angular/common/http';
+
+import { Actions, createEffect, ofType, concatLatestFrom } from '@ngrx/effects';
+import { Store } from '@ngrx/store';
+import { of } from 'rxjs';
+import { map, tap, mergeMap, catchError } from 'rxjs/operators';
+import { ToastrService } from 'ngx-toastr';
+
+import { DownloadFileService } from '../services/download-file.service';
+import * as downloadFileActions from '../actions/download-file.actions';
+
+/**
+ * @class
+ * @classdesc File effects.
+ */
+@Injectable()
+export class DownloadFileEffects {
+    /**
+     * Calls actions to retrieve object.
+     */
+    startsDownloadingFile$ = createEffect(() =>
+        this.actions$.pipe(
+            ofType(downloadFileActions.startsDownloadingFile),
+            mergeMap((action) => this.downloadFileService.startsDownloadingFile(action.url)
+                .pipe(
+                    map(event => {
+                        if (event.type === HttpEventType.DownloadProgress) {
+                            this.store.dispatch(downloadFileActions.updateDownloadProgress({ 
+                                progress: Math.round((100 * event.loaded) / event.total),
+                                filename: action.filename
+                            }));
+                        }
+
+                        if (event.type === HttpEventType.Response) {
+                            this.downloadFileService.saveDownloadedFile(event.body as Blob, action.filename);
+                        }
+                    })
+                )
+            )
+        ), { dispatch: false }
+    );
+
+    constructor(
+        private actions$: Actions,
+        private downloadFileService: DownloadFileService,
+        private store: Store<{ }>,
+        private toastr: ToastrService
+    ) {}
+}
diff --git a/client/src/app/instance/store/effects/index.ts b/client/src/app/instance/store/effects/index.ts
index 69b9a3b2..18cb99de 100644
--- a/client/src/app/instance/store/effects/index.ts
+++ b/client/src/app/instance/store/effects/index.ts
@@ -4,6 +4,7 @@ import { SearchMultipleEffects } from './search-multiple.effects';
 import { ConeSearchEffects } from './cone-search.effects';
 import { DetailEffects } from './detail.effects';
 import { SvomJsonKwEffects } from './svom-json-kw.effects';
+import { DownloadFileEffects } from './download-file.effects';
 
 export const instanceEffects = [
     SampEffects,
@@ -11,5 +12,6 @@ export const instanceEffects = [
     SearchMultipleEffects,
     ConeSearchEffects,
     DetailEffects,
-    SvomJsonKwEffects
+    SvomJsonKwEffects,
+    DownloadFileEffects
 ];
diff --git a/client/src/app/instance/store/models/download-file.model.ts b/client/src/app/instance/store/models/download-file.model.ts
new file mode 100644
index 00000000..d4d08f45
--- /dev/null
+++ b/client/src/app/instance/store/models/download-file.model.ts
@@ -0,0 +1,14 @@
+/**
+ * This file is part of Anis Client.
+ *
+ * @copyright Laboratoire d'Astrophysique de Marseille / CNRS
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+export interface DownloadFile {
+    name: string,
+    state: 'PENDING' | 'IN_PROGRESS' | 'DONE'
+    progress: number
+}
diff --git a/client/src/app/instance/store/models/index.ts b/client/src/app/instance/store/models/index.ts
index 2570f163..3b2241bc 100644
--- a/client/src/app/instance/store/models/index.ts
+++ b/client/src/app/instance/store/models/index.ts
@@ -7,4 +7,5 @@ export * from './cone-search.model';
 export * from './resolver.model';
 export * from './search-multiple-dataset-length';
 export * from './search-multiple-dataset-data';
-export * from './svom-keyword.model';
\ No newline at end of file
+export * from './svom-keyword.model';
+export * from './download-file.model';
diff --git a/client/src/app/instance/store/reducers/download-file.reducer.ts b/client/src/app/instance/store/reducers/download-file.reducer.ts
new file mode 100644
index 00000000..c0021b46
--- /dev/null
+++ b/client/src/app/instance/store/reducers/download-file.reducer.ts
@@ -0,0 +1,51 @@
+/**
+ * This file is part of Anis Client.
+ *
+ * @copyright Laboratoire d'Astrophysique de Marseille / CNRS
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+import { createReducer, on } from '@ngrx/store';
+
+import { DownloadFile } from '../models';
+import * as downloadFileActions from '../actions/download-file.actions';
+
+/**
+ * Interface for file state.
+ *
+ * @interface State
+ */
+export interface State {
+    downloadedFiles: DownloadFile[]
+}
+
+export const initialState: State = {
+    downloadedFiles: []
+};
+
+export const fileReducer = createReducer(
+    initialState,
+    on(downloadFileActions.startsDownloadingFile, (state, { filename }) => ({
+        ...state,
+        downloadedFiles: [...state.downloadedFiles, {
+            name: filename,
+            state: 'PENDING',
+            progress: 0
+        }]
+    })),
+    on(downloadFileActions.updateDownloadProgress, (state, { progress, filename }) => ({
+        ...state,
+        downloadedFiles: [
+            ...state.downloadedFiles.filter(f => f.name !== filename),
+            {
+                name: filename,
+                state: 'IN_PROGRESS',
+                progress: progress
+            }
+        ]
+    }))
+);
+
+export const selectDownloadedFiles = (state: State) => state.downloadedFiles;
\ No newline at end of file
diff --git a/client/src/app/instance/store/selectors/download-file.selector.ts b/client/src/app/instance/store/selectors/download-file.selector.ts
new file mode 100644
index 00000000..b955521d
--- /dev/null
+++ b/client/src/app/instance/store/selectors/download-file.selector.ts
@@ -0,0 +1,23 @@
+/**
+ * This file is part of Anis Client.
+ *
+ * @copyright Laboratoire d'Astrophysique de Marseille / CNRS
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+import { createSelector } from '@ngrx/store';
+
+import * as reducer from '../../instance.reducer';
+import * as fromDownloadFile from '../reducers/download-file.reducer';
+
+export const selectDownloadFileState = createSelector(
+    reducer.getInstanceState,
+    (state: reducer.State) => state.downloadFile
+);
+
+export const selectDownloadedFiles = createSelector(
+    selectDownloadFileState,
+    fromDownloadFile.selectDownloadedFiles
+);
diff --git a/client/src/app/instance/store/services/download-file.service.ts b/client/src/app/instance/store/services/download-file.service.ts
new file mode 100644
index 00000000..663e0521
--- /dev/null
+++ b/client/src/app/instance/store/services/download-file.service.ts
@@ -0,0 +1,34 @@
+/**
+ * This file is part of Anis Client.
+ *
+ * @copyright Laboratoire d'Astrophysique de Marseille / CNRS
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+import { Injectable } from '@angular/core';
+import { HttpClient, HttpRequest } from '@angular/common/http';
+
+@Injectable({providedIn: 'root'})
+export class DownloadFileService {
+    constructor(private http: HttpClient) { }
+
+    startsDownloadingFile(url: string) {
+        const request = new HttpRequest(
+            "GET",
+            url,
+            {},
+            { reportProgress: true, responseType: 'blob' }
+        );
+
+        return this.http.request(request);
+    }
+
+    saveDownloadedFile(body: Blob, filename: string) {
+        let downloadLink = document.createElement('a');
+        downloadLink.href = window.URL.createObjectURL(body);
+        downloadLink.setAttribute('download', filename);
+        downloadLink.click();
+    }
+}
\ No newline at end of file
diff --git a/client/src/app/instance/store/services/index.ts b/client/src/app/instance/store/services/index.ts
index 795315e9..d70ddbf8 100644
--- a/client/src/app/instance/store/services/index.ts
+++ b/client/src/app/instance/store/services/index.ts
@@ -3,11 +3,13 @@ import { SampService } from './samp.service';
 import { ConeSearchService } from './cone-search.service';
 import { DetailService } from './detail.service';
 import { SvomJsonKwService } from './svom-json-kw.service';
+import { DownloadFileService } from './download-file.service';
 
 export const instanceServices = [
     SearchService,
     SampService,
     ConeSearchService,
     DetailService,
-    SvomJsonKwService
+    SvomJsonKwService,
+    DownloadFileService
 ];
diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts
index bfb8594e..5455daca 100644
--- a/client/src/app/shared/shared.module.ts
+++ b/client/src/app/shared/shared.module.ts
@@ -20,6 +20,7 @@ import { TooltipModule } from 'ngx-bootstrap/tooltip';
 import { BsDatepickerModule } from 'ngx-bootstrap/datepicker';
 import { TabsModule } from 'ngx-bootstrap/tabs';
 import { PaginationModule } from 'ngx-bootstrap/pagination';
+import { ProgressbarModule } from 'ngx-bootstrap/progressbar';
 import { NgSelectModule } from '@ng-select/ng-select';
 import { NgxJsonViewerModule } from 'ngx-json-viewer';
 
@@ -48,6 +49,7 @@ import { sharedPipes } from './pipes';
         BsDatepickerModule.forRoot(),
         TabsModule.forRoot(),
         PaginationModule.forRoot(),
+        ProgressbarModule.forRoot(),
         NgSelectModule,
         NgxJsonViewerModule
     ],
@@ -63,6 +65,7 @@ import { sharedPipes } from './pipes';
         BsDatepickerModule,
         TabsModule,
         PaginationModule,
+        ProgressbarModule,
         NgSelectModule,
         NgxJsonViewerModule,
         sharedComponents,
-- 
GitLab