From 31493fbc34398efbb0945e4256a963d87fe9781e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fran=C3=A7ois=20Agneray?= <francois.agneray@lam.fr>
Date: Wed, 7 Jul 2021 22:22:51 +0200
Subject: [PATCH] Search result => WIP

---
 .../criteria/criteria-tabs.component.ts       |   2 +-
 .../app/instance/search/components/index.ts   |   4 +-
 .../search/components/output/index.ts         |   9 +
 .../output/output-by-category.component.html  |  29 ++++
 .../output/output-by-category.component.scss  |  27 +++
 .../output/output-by-category.component.ts    |  99 +++++++++++
 .../output/output-by-family.component.html    |  13 ++
 .../output/output-by-family.component.ts      |  95 ++++++++++
 .../output/output-tabs.component.html         |  35 ++++
 .../output/output-tabs.component.ts           |  33 ++++
 .../search/containers/criteria.component.html |   2 +-
 .../search/containers/output.component.html   |  49 ++++++
 .../search/containers/output.component.ts     | 113 ++++++++++++
 .../search/containers/result.component.html   |  87 ++++++++++
 .../search/containers/result.component.ts     | 162 ++++++++++++++++++
 .../instance/search/search-routing.module.ts  |   7 +-
 .../instance/store/actions/search.actions.ts  |  11 +-
 client/src/app/instance/store/models/index.ts |   1 +
 .../instance/store/models/pagination.model.ts |  31 ++++
 .../instance/store/reducers/search.reducer.ts |  13 ++
 client/src/styles.scss                        |   4 +
 21 files changed, 820 insertions(+), 6 deletions(-)
 create mode 100644 client/src/app/instance/search/components/output/index.ts
 create mode 100644 client/src/app/instance/search/components/output/output-by-category.component.html
 create mode 100644 client/src/app/instance/search/components/output/output-by-category.component.scss
 create mode 100644 client/src/app/instance/search/components/output/output-by-category.component.ts
 create mode 100644 client/src/app/instance/search/components/output/output-by-family.component.html
 create mode 100644 client/src/app/instance/search/components/output/output-by-family.component.ts
 create mode 100644 client/src/app/instance/search/components/output/output-tabs.component.html
 create mode 100644 client/src/app/instance/search/components/output/output-tabs.component.ts
 create mode 100644 client/src/app/instance/search/containers/output.component.html
 create mode 100644 client/src/app/instance/search/containers/output.component.ts
 create mode 100644 client/src/app/instance/search/containers/result.component.html
 create mode 100644 client/src/app/instance/search/containers/result.component.ts
 create mode 100644 client/src/app/instance/store/models/pagination.model.ts

diff --git a/client/src/app/instance/search/components/criteria/criteria-tabs.component.ts b/client/src/app/instance/search/components/criteria/criteria-tabs.component.ts
index a4d86a97..7c28f7e9 100644
--- a/client/src/app/instance/search/components/criteria/criteria-tabs.component.ts
+++ b/client/src/app/instance/search/components/criteria/criteria-tabs.component.ts
@@ -22,8 +22,8 @@ import { CriteriaFamily, Attribute } from 'src/app/metamodel/models';
  * @classdesc Search criteria tabs component.
  */
 export class CriteriaTabsComponent {
-    @Input() criteriaFamilyList: CriteriaFamily[];
     @Input() attributeList: Attribute[];
+    @Input() criteriaFamilyList: CriteriaFamily[];
     @Input() criteriaList: Criterion[];
     @Output() addCriterion: EventEmitter<Criterion> = new EventEmitter();
     @Output() deleteCriterion: EventEmitter<number> = new EventEmitter();
diff --git a/client/src/app/instance/search/components/index.ts b/client/src/app/instance/search/components/index.ts
index 45bd8ffe..7e080062 100644
--- a/client/src/app/instance/search/components/index.ts
+++ b/client/src/app/instance/search/components/index.ts
@@ -2,10 +2,12 @@ import { ProgressBarComponent } from './progress-bar.component';
 import { SummaryComponent } from './summary.component';
 import { datasetComponents } from './dataset';
 import { criteriaComponents } from './criteria';
+import { outputComponents } from './output';
 
 export const dummiesComponents = [
     ProgressBarComponent,
     SummaryComponent,
     datasetComponents,
-    criteriaComponents
+    criteriaComponents,
+    outputComponents
 ];
\ No newline at end of file
diff --git a/client/src/app/instance/search/components/output/index.ts b/client/src/app/instance/search/components/output/index.ts
new file mode 100644
index 00000000..37dfe1f8
--- /dev/null
+++ b/client/src/app/instance/search/components/output/index.ts
@@ -0,0 +1,9 @@
+import { OutputTabsComponent } from "./output-tabs.component";
+import { OutputByFamilyComponent } from "./output-by-family.component";
+import { OutputByCategoryComponent } from "./output-by-category.component";
+
+export const outputComponents = [
+    OutputTabsComponent,
+    OutputByFamilyComponent,
+    OutputByCategoryComponent
+];
diff --git a/client/src/app/instance/search/components/output/output-by-category.component.html b/client/src/app/instance/search/components/output/output-by-category.component.html
new file mode 100644
index 00000000..9774bd95
--- /dev/null
+++ b/client/src/app/instance/search/components/output/output-by-category.component.html
@@ -0,0 +1,29 @@
+<p class="mb-3"><em>{{ categoryLabel }}</em></p>
+<div class="row mb-1">
+    <div class="col pr-1">
+        <button (click)="selectAll()" [disabled]="isAllSelected"
+            class="btn btn-sm btn-block btn-outline-secondary letter-spacing">
+            Select All
+        </button>
+    </div>
+    <div class="col pl-1">
+        <button (click)="unselectAll()" [disabled]="isAllUnselected"
+            class="btn btn-sm btn-block btn-outline-secondary letter-spacing">
+            Unselect All
+        </button>
+    </div>
+</div>
+<div class="selectbox p-0">
+    <div *ngFor="let attribute of getAttributeListSortedByDisplay()">
+        <div *ngIf="isSelected(attribute.id)">
+            <button class="btn btn-block text-left py-1 m-0 rounded-0" (click)="toggleSelection(attribute.id)">
+                <span class="fas fa-fw fa-check-square theme-color"></span> {{ attribute.form_label }}
+            </button>
+        </div>
+        <div *ngIf="!isSelected(attribute.id)">
+            <button class="btn btn-block text-left py-1 m-0 rounded-0" (click)="toggleSelection(attribute.id)">
+                <span class="far fa-fw fa-square text-secondary"></span> {{ attribute.form_label }}
+            </button>
+        </div>
+    </div>
+</div>
diff --git a/client/src/app/instance/search/components/output/output-by-category.component.scss b/client/src/app/instance/search/components/output/output-by-category.component.scss
new file mode 100644
index 00000000..0320f4ee
--- /dev/null
+++ b/client/src/app/instance/search/components/output/output-by-category.component.scss
@@ -0,0 +1,27 @@
+/**
+ * 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.
+ */
+
+ .selectbox {
+    height: 200px;
+    overflow-y: auto;
+    border: 1px solid #ced4da;
+    border-radius: .25rem;
+}
+
+.letter-spacing {
+    letter-spacing: 2px;
+}
+
+.selectbox button:hover {
+    background-color: #ECECEC;
+}
+
+.selectbox button:focus {
+    box-shadow: none;
+}
diff --git a/client/src/app/instance/search/components/output/output-by-category.component.ts b/client/src/app/instance/search/components/output/output-by-category.component.ts
new file mode 100644
index 00000000..b65113b6
--- /dev/null
+++ b/client/src/app/instance/search/components/output/output-by-category.component.ts
@@ -0,0 +1,99 @@
+/**
+ * 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, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core';
+
+import { Attribute } from 'src/app/metamodel/models';
+
+@Component({
+    selector: 'app-output-by-category',
+    templateUrl: 'output-by-category.component.html',
+    styleUrls: ['output-by-category.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush
+})
+/**
+ * @class
+ * @classdesc Search output by category component.
+ */
+export class OutputByCategoryComponent {
+    @Input() categoryLabel: string;
+    @Input() attributeList: Attribute[];
+    @Input() outputList: number[];
+    @Input() isAllSelected: boolean;
+    @Input() isAllUnselected: boolean;
+    @Output() change: EventEmitter<number[]> = new EventEmitter();
+
+    /**
+     * Returns output list sorted by output display.
+     *
+     * @return Attribute[]
+     */
+    getAttributeListSortedByDisplay(): Attribute[] {
+        return this.attributeList
+            .sort((a, b) => a.output_display - b.output_display);
+    }
+
+    /**
+     * Checks if the given output ID is selected.
+     *
+     * @param  {number} id - The output ID.
+     *
+     * @return boolean
+     */
+    isSelected(id: number): boolean {
+        return this.outputList.filter(i => i === id).length > 0;
+    }
+
+    /**
+     * Toggles output selection for the given attribute ID and emits updated output list.
+     *
+     * @param  {number} attributeId - The attribute ID.
+     *
+     * @fires EventEmitter<number[]>
+     */
+    toggleSelection(attributeId: number): void {
+        const clonedOutputList = [...this.outputList];
+        const index = clonedOutputList.indexOf(attributeId);
+        if (index > -1) {
+            clonedOutputList.splice(index, 1);
+        } else {
+            clonedOutputList.push(attributeId);
+        }
+        this.change.emit(clonedOutputList);
+    }
+
+    /**
+     * Selects all attributes and emits updated output list.
+     *
+     * @fires EventEmitter<number[]>
+     */
+    selectAll(): void {
+        const clonedOutputList = [...this.outputList];
+        const attributeListId = this.attributeList.map(a => a.id);
+        attributeListId.filter(id => clonedOutputList.indexOf(id) === -1).forEach(id => {
+            clonedOutputList.push(id);
+        });
+        this.change.emit(clonedOutputList);
+    }
+
+    /**
+     * Unselects all attributes and emits updated output list.
+     *
+     * @fires EventEmitter<number[]>
+     */
+    unselectAll(): void {
+        const clonedOutputList = [...this.outputList];
+        const attributeListId = this.attributeList.map(a => a.id);
+        attributeListId.filter(id => clonedOutputList.indexOf(id) > -1).forEach(id => {
+            const index = clonedOutputList.indexOf(id);
+            clonedOutputList.splice(index, 1);
+        });
+        this.change.emit(clonedOutputList);
+    }
+}
diff --git a/client/src/app/instance/search/components/output/output-by-family.component.html b/client/src/app/instance/search/components/output/output-by-family.component.html
new file mode 100644
index 00000000..c281d869
--- /dev/null
+++ b/client/src/app/instance/search/components/output/output-by-family.component.html
@@ -0,0 +1,13 @@
+<div class="row">
+    <div *ngFor="let category of getCategoryByFamilySortedByDisplay(outputFamily.id)"
+        class="col-12 col-md-6 my-3 text-center">
+        <app-output-by-category 
+            [categoryLabel]="category.label" 
+            [attributeList]="getAttributeByCategory(category.id)"
+            [outputList]="outputList" 
+            [isAllSelected]="getIsAllSelected(category.id)"
+            [isAllUnselected]="getIsAllUnselected(category.id)" 
+            (change)="emitChange($event)">
+        </app-output-by-category>
+    </div>
+</div>
diff --git a/client/src/app/instance/search/components/output/output-by-family.component.ts b/client/src/app/instance/search/components/output/output-by-family.component.ts
new file mode 100644
index 00000000..8ef26caf
--- /dev/null
+++ b/client/src/app/instance/search/components/output/output-by-family.component.ts
@@ -0,0 +1,95 @@
+/**
+ * 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, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core';
+
+import { OutputFamily, OutputCategory, Attribute } from 'src/app/metamodel/models';
+
+@Component({
+    selector: 'app-output-by-family',
+    templateUrl: 'output-by-family.component.html',
+    changeDetection: ChangeDetectionStrategy.OnPush
+})
+/**
+ * @class
+ * @classdesc Search output by family component.
+ */
+export class OutputByFamilyComponent {
+    @Input() outputFamily: OutputFamily;
+    @Input() outputCategoryList: OutputCategory[];
+    @Input() attributeList: Attribute[];
+    @Input() outputList: number[];
+    @Output() change: EventEmitter<number[]> = new EventEmitter();
+
+    /**
+     * Returns category list sorted by display, for the given output family ID.
+     *
+     * @param  {number} idFamily - The output family ID.
+     *
+     * @return Category[]
+     */
+    getCategoryByFamilySortedByDisplay(idFamily: number): OutputCategory[] {
+        return this.outputCategoryList
+            .filter(category => category.id_output_family === idFamily);
+    }
+
+    /**
+     * Returns output list that belongs to the given category ID.
+     *
+     * @param  {number} idCategory - The output category ID.
+     *
+     * @return Attribute[]
+     */
+    getAttributeByCategory(idCategory: number): Attribute[] {
+        return this.attributeList
+            .filter(attribute => attribute.id_output_category === idCategory);
+    }
+
+    /**
+     * Checks if all outputs for the given category ID are selected.
+     *
+     * @param  {number} idCategory - The output category ID.
+     *
+     * @return boolean
+     */
+    getIsAllSelected(idCategory: number): boolean {
+        const attributeListId = this.getAttributeByCategory(idCategory).map(a => a.id);
+        const filteredOutputList = this.outputList.filter(id => attributeListId.indexOf(id) > -1);
+        return attributeListId.length === filteredOutputList.length;
+    }
+
+    /**
+     * Checks if all outputs for the given category ID are unselected.
+     *
+     * @param  {number} idCategory - The output category ID.
+     *
+     * @return boolean
+     */
+    getIsAllUnselected(idCategory: number): boolean {
+        const attributeListId = this.getAttributeByCategory(idCategory).map(a => a.id);
+        const filteredOutputList = this.outputList.filter(id => attributeListId.indexOf(id) > -1);
+        return filteredOutputList.length === 0;
+    }
+
+    /**
+     * Emits update output list event with updated sorted output list given.
+     *
+     * @param  {number[]} clonedOutputList - The updated output list.
+     *
+     * @fires EventEmitter<number[]>
+     */
+    emitChange(clonedOutputList: number[]): void {
+        this.change.emit(
+            this.attributeList
+                .filter(a => clonedOutputList.indexOf(a.id) > -1)
+                .sort((a, b) => a.output_display - b.output_display)
+                .map(a => a.id)
+        );
+    }
+}
diff --git a/client/src/app/instance/search/components/output/output-tabs.component.html b/client/src/app/instance/search/components/output/output-tabs.component.html
new file mode 100644
index 00000000..d8ca9de4
--- /dev/null
+++ b/client/src/app/instance/search/components/output/output-tabs.component.html
@@ -0,0 +1,35 @@
+<div *ngIf="outputFamilyList.length == 1">
+    <div class="border rounded my-2">
+        <p class="border-bottom bg-light text-primary py-4 pl-4 mb-0">{{ outputFamilyList[0].label }}</p>
+        <div class="px-3 pb-3 pt-0">
+            <app-output-by-family
+                [outputFamily]="outputFamilyList[0]" 
+                [attributeList]="attributeList"
+                [outputCategoryList]="outputCategoryList" 
+                [outputList]="outputList"
+                (change)="change.emit($event)">
+            </app-output-by-family>
+        </div>
+    </div>
+</div>
+
+<accordion *ngIf="outputFamilyList.length > 1" [isAnimated]="true">
+    <accordion-group #ag *ngFor="let family of outputFamilyList"
+        [panelClass]="'custom-accordion-output'" class="my-2" [isOpen]="true">
+        <button class="btn btn-link btn-block clearfix" accordion-heading>
+            <span class="pull-left float-left">
+                {{ family.label }}
+                &nbsp;
+                <span *ngIf="ag.isOpen"><span class="fas fa-chevron-up"></span></span>
+                <span *ngIf="!ag.isOpen"><span class="fas fa-chevron-down"></span></span>
+            </span>
+        </button>
+        <app-output-by-family 
+            [outputFamily]="family" 
+            [attributeList]="attributeList"
+            [outputCategoryList]="outputCategoryList" 
+            [outputList]="outputList"
+            (change)="change.emit($event)">
+        </app-output-by-family>
+    </accordion-group>
+</accordion>
diff --git a/client/src/app/instance/search/components/output/output-tabs.component.ts b/client/src/app/instance/search/components/output/output-tabs.component.ts
new file mode 100644
index 00000000..c3ec9111
--- /dev/null
+++ b/client/src/app/instance/search/components/output/output-tabs.component.ts
@@ -0,0 +1,33 @@
+/**
+ * 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, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core';
+
+import { ToastrService } from 'ngx-toastr';
+
+import { OutputFamily, OutputCategory, Attribute } from 'src/app/metamodel/models';
+
+@Component({
+    selector: 'app-output-tabs',
+    templateUrl: 'output-tabs.component.html',
+    changeDetection: ChangeDetectionStrategy.OnPush
+})
+/**
+ * @class
+ * @classdesc Search output tab component.
+ */
+export class OutputTabsComponent {
+    @Input() outputFamilyList: OutputFamily[];
+    @Input() outputCategoryList: OutputCategory[];
+    @Input() attributeList: Attribute[];
+    @Input() outputList: number[];
+    @Output() change: EventEmitter<number[]> = new EventEmitter();
+
+    constructor(private toastr: ToastrService) { }
+}
diff --git a/client/src/app/instance/search/containers/criteria.component.html b/client/src/app/instance/search/containers/criteria.component.html
index 3647b626..97ca115b 100644
--- a/client/src/app/instance/search/containers/criteria.component.html
+++ b/client/src/app/instance/search/containers/criteria.component.html
@@ -10,8 +10,8 @@
             (coneSearchAdded)="coneSearchAdded($event)">
         </app-cone-search-tab> -->
         <app-criteria-tabs 
-            [criteriaFamilyList]="criteriaFamilyList | async"
             [attributeList]="attributeList | async"
+            [criteriaFamilyList]="criteriaFamilyList | async"
             [criteriaList]="criteriaList | async"
             (addCriterion)="addCriterion($event)" 
             (deleteCriterion)="deleteCriterion($event)">
diff --git a/client/src/app/instance/search/containers/output.component.html b/client/src/app/instance/search/containers/output.component.html
new file mode 100644
index 00000000..ece692c3
--- /dev/null
+++ b/client/src/app/instance/search/containers/output.component.html
@@ -0,0 +1,49 @@
+<app-spinner *ngIf="(attributeListIsLoading | async) || (outputFamilyListIsLoading | async) || (outputCategoryListIsLoading | async)"></app-spinner>
+
+<div *ngIf="(attributeListIsLoaded | async) && (outputFamilyListIsLoaded | async) && (outputCategoryListIsLoaded | async)"
+    class="row mt-4">
+    <div class="col-12 col-md-8 col-lg-9">
+        <app-output-tabs 
+            [attributeList]="attributeList | async"
+            [outputFamilyList]="outputFamilyList | async" 
+            [outputCategoryList]="outputCategoryList | async"
+            [outputList]="outputList | async"
+            (change)="updateOutputList($event)">
+        </app-output-tabs>
+    </div>
+    <div class="col-12 col-md-4 col-lg-3 pt-2">
+        <app-spinner *ngIf="(datasetListIsLoading | async) || (criteriaFamilyListIsLoading | async)"></app-spinner>
+        <app-summary *ngIf="(datasetListIsLoaded | async) && (criteriaFamilyListIsLoaded | async)"
+            [currentStep]="currentStep | async"
+            [datasetSelected]="datasetSelected | async"
+            [datasetList]="datasetList | async"
+            [attributeList]="attributeList | async"
+            [criteriaFamilyList]="criteriaFamilyList | async"
+            [outputFamilyList]="outputFamilyList | async"
+            [outputCategoryList]="outputCategoryList | async"
+            [criteriaList]="criteriaList | async"
+            [outputList]="outputList | async"
+            [queryParams]="queryParams | async">
+        </app-summary>
+    </div>
+</div>
+<div class="row mt-5 justify-content-between">
+    <div class="col">
+        <a routerLink="/instance/{{ instanceSelected | async }}/search/criteria/{{ datasetSelected | async }}" [queryParams]="queryParams | async"
+            class="btn btn-outline-secondary">
+            <span class="fas fa-arrow-left"></span> Criteria
+        </a>
+    </div>
+    <!-- Simplifier ? -->
+    <div class="col col-auto">
+        <button *ngIf="(outputList | async).length < 1; else notEmpty" class="btn btn-outline-primary disabled not-allowed" title="At least 1 output required!">
+            Result <span class="fas fa-arrow-right"></span>
+        </button>
+        <ng-template #notEmpty>
+            <a routerLink="/instance/{{ instanceSelected | async }}/search/result/{{ datasetSelected | async }}" [queryParams]="queryParams | async"
+                class="btn btn-outline-primary">
+                Result <span class="fas fa-arrow-right"></span>
+            </a>
+        </ng-template>
+    </div>
+</div>
diff --git a/client/src/app/instance/search/containers/output.component.ts b/client/src/app/instance/search/containers/output.component.ts
new file mode 100644
index 00000000..69404cdf
--- /dev/null
+++ b/client/src/app/instance/search/containers/output.component.ts
@@ -0,0 +1,113 @@
+/**
+ * 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, OnInit } from '@angular/core';
+
+import { Store } from '@ngrx/store';
+import { Observable } from 'rxjs';
+
+import { Criterion, SearchQueryParams } from '../../store/models';
+import { Dataset, CriteriaFamily, OutputFamily, Attribute, OutputCategory } from 'src/app/metamodel/models';
+import * as instanceSelector from 'src/app/metamodel/selectors/instance.selector';
+import * as datasetActions from 'src/app/metamodel/actions/dataset.actions';
+import * as datasetSelector from 'src/app/metamodel/selectors/dataset.selector';
+import * as attributeActions from 'src/app/metamodel/actions/attribute.actions';
+import * as attributeSelector from 'src/app/metamodel/selectors/attribute.selector';
+import * as criteriaFamilyActions from 'src/app/metamodel/actions/criteria-family.actions';
+import * as criteriaFamilySelector from 'src/app/metamodel/selectors/criteria-family.selector';
+import * as outputFamilyActions from 'src/app/metamodel/actions/output-family.actions';
+import * as outputFamilySelector from 'src/app/metamodel/selectors/output-family.selector';
+import * as outputCategoryActions from 'src/app/metamodel/actions/output-category.actions';
+import * as outputCategorySelector from 'src/app/metamodel/selectors/output-category.selector';
+import * as searchActions from '../../store/actions/search.actions';
+import * as searchSelector from '../../store/selectors/search.selector';
+
+@Component({
+    selector: 'app-output',
+    templateUrl: 'output.component.html'
+})
+/**
+ * @class
+ * @classdesc Search output container.
+ *
+ * @implements OnInit
+ */
+export class OutputComponent implements OnInit {
+    public datasetSelected: Observable<string>;
+    public instanceSelected: Observable<string>;
+    public datasetListIsLoading: Observable<boolean>;
+    public datasetListIsLoaded: Observable<boolean>;
+    public datasetList: Observable<Dataset[]>;
+    public attributeList: Observable<Attribute[]>;
+    public attributeListIsLoading: Observable<boolean>;
+    public attributeListIsLoaded: Observable<boolean>;
+    public criteriaFamilyList: Observable<CriteriaFamily[]>;
+    public criteriaFamilyListIsLoading: Observable<boolean>;
+    public criteriaFamilyListIsLoaded: Observable<boolean>;
+    public outputFamilyList: Observable<OutputFamily[]>;
+    public outputFamilyListIsLoading: Observable<boolean>;
+    public outputFamilyListIsLoaded: Observable<boolean>;
+    public outputCategoryList: Observable<OutputCategory[]>;
+    public outputCategoryListIsLoading: Observable<boolean>;
+    public outputCategoryListIsLoaded: Observable<boolean>;
+    public currentStep: Observable<string>;
+    public criteriaList: Observable<Criterion[]>;
+    public outputList: Observable<number[]>;
+    public queryParams: Observable<SearchQueryParams>;
+
+    constructor(private store: Store<{ }>) {
+        this.datasetSelected = store.select(datasetSelector.selectDatasetNameByRoute);
+        this.instanceSelected = store.select(instanceSelector.selectInstanceNameByRoute);
+        this.datasetListIsLoading = store.select(datasetSelector.selectDatasetListIsLoading);
+        this.datasetListIsLoaded = store.select(datasetSelector.selectDatasetListIsLoaded);
+        this.datasetList = store.select(datasetSelector.selectAllDatasets);
+        this.attributeList = store.select(attributeSelector.selectAllAttributes);
+        this.attributeListIsLoading = store.select(attributeSelector.selectAttributeListIsLoading);
+        this.attributeListIsLoaded = store.select(attributeSelector.selectAttributeListIsLoaded);
+        this.criteriaFamilyList = store.select(criteriaFamilySelector.selectAllCriteriaFamilies);
+        this.criteriaFamilyListIsLoading = store.select(criteriaFamilySelector.selectCriteriaFamilyListIsLoading);
+        this.criteriaFamilyListIsLoaded = store.select(criteriaFamilySelector.selectCriteriaFamilyListIsLoaded);
+        this.outputFamilyList = store.select(outputFamilySelector.selectAllOutputFamilies);
+        this.outputFamilyListIsLoading = store.select(outputFamilySelector.selectOutputFamilyListIsLoading);
+        this.outputFamilyListIsLoaded = store.select(outputFamilySelector.selectOutputFamilyListIsLoaded);
+        this.outputCategoryList = store.select(outputCategorySelector.selectAllOutputCategories);
+        this.outputCategoryListIsLoading = store.select(outputCategorySelector.selectOutputCategoryListIsLoading);
+        this.outputCategoryListIsLoaded = store.select(outputCategorySelector.selectOutputCategoryListIsLoaded);
+        this.currentStep = this.store.select(searchSelector.selectCurrentStep);
+        this.criteriaList = this.store.select(searchSelector.selectCriteriaList);
+        this.outputList = this.store.select(searchSelector.selectOutputList);
+        this.queryParams = this.store.select(searchSelector.selectQueryParams);
+    }
+
+    ngOnInit() {
+        // Create a micro task that is processed after the current synchronous code
+        // This micro task prevent the expression has changed after view init error
+        Promise.resolve(null).then(() => this.store.dispatch(searchActions.changeStep({ step: 'output' })));
+        Promise.resolve(null).then(() => this.store.dispatch(searchActions.checkOutput()));
+
+        this.store.dispatch(datasetActions.loadDatasetList());
+        this.datasetSelected.subscribe(dname => {
+            if (dname) {
+                this.store.dispatch(attributeActions.loadAttributeList());
+                this.store.dispatch(criteriaFamilyActions.loadCriteriaFamilyList());
+                this.store.dispatch(outputFamilyActions.loadOutputFamilyList());
+                this.store.dispatch(outputCategoryActions.loadOutputCategoryList());
+            }
+        });
+    }
+
+    /**
+     * Dispatches action to update output list selection with the given updated output list.
+     *
+     * @param  {number[]} outputList - The updated output list.
+     */
+    updateOutputList(outputList: number[]): void {
+        this.store.dispatch(searchActions.updateOutputList({ outputList }));
+    }
+}
diff --git a/client/src/app/instance/search/containers/result.component.html b/client/src/app/instance/search/containers/result.component.html
new file mode 100644
index 00000000..5d02caec
--- /dev/null
+++ b/client/src/app/instance/search/containers/result.component.html
@@ -0,0 +1,87 @@
+<div *ngIf="(datasetSearchMetaIsLoading | async) || (attributeListIsLoading | async) || !(dataLengthIsLoaded | async)"
+    class="row justify-content-center mt-5">
+    <span class="fas fa-circle-notch fa-spin fa-3x"></span>
+    <span class="sr-only">Loading...</span>
+</div>
+
+<div *ngIf="(datasetSearchMetaIsLoaded | async) && (attributeListIsLoaded | async)" class="row mt-4">
+    <div class="col-12">
+        <app-result-download
+            [datasetName]="datasetName | async"
+            [datasetList]="datasetList | async"
+            [dataLengthIsLoaded]="dataLengthIsLoaded | async"
+            [dataLength]="dataLength | async"
+            [isConeSearchAdded]="isConeSearchAdded | async"
+            [coneSearch]="coneSearch | async"
+            [criteriaList]="criteriaList | async"
+            [outputList]="outputList | async"
+            [sampRegistered]="sampRegistered | async"
+            (getDataLength)="getDataLength()"
+            (broadcast)="broadcastVotable($event)">
+        </app-result-download>
+        <app-reminder
+            [datasetName]="datasetName | async"
+            [datasetList]="datasetList | async"
+            [datasetAttributeList]="attributeList | async"
+            [dataLengthIsLoaded]="dataLengthIsLoaded | async"
+            [isConeSearchAdded]="isConeSearchAdded | async"
+            [coneSearch]="coneSearch | async"
+            [criteriaFamilyList]="criteriaFamilyList | async"
+            [criteriaList]="criteriaList | async"
+            [outputFamilyList]="outputFamilyList | async"
+            [categoryList]="categoryList | async"
+            [outputList]="outputList | async">
+        </app-reminder>
+        <app-samp
+            [datasetName]="datasetName | async"
+            [datasetList]="datasetList | async"
+            [sampRegistered]="sampRegistered | async"
+            (sampRegister)="sampRegister()"
+            (sampUnregister)="sampUnregister()">
+        </app-samp>
+        <app-result-url-display
+            [datasetList]="datasetList | async"
+            [dataLengthIsLoaded]="dataLengthIsLoaded | async"
+            [datasetName]="datasetName | async"
+            [isConeSearchAdded]="isConeSearchAdded | async"
+            [coneSearch]="coneSearch | async"
+            [criteriaList]="criteriaList | async"
+            [outputList]="outputList | async">
+        </app-result-url-display>
+        <app-cone-search-plot-tab
+            [datasetName]="datasetName | async"
+            [datasetList]="datasetList | async"
+            [datasetAttributeList]="attributeList | async"
+            [searchData]="searchData | async"
+            [dataLengthIsLoaded]="dataLengthIsLoaded | async"
+            [dataLength]="dataLength | async"
+            [isConeSearchAdded]="isConeSearchAdded | async"
+            [coneSearch]="coneSearch | async">
+        </app-cone-search-plot-tab>
+        <app-datatable-tab
+            [datasetName]="datasetName | async"
+            [datasetList]="datasetList | async"
+            [datasetAttributeList]="attributeList | async"
+            [outputList]="outputList | async"
+            [searchData]="searchData | async"
+            [dataLengthIsLoaded]="dataLengthIsLoaded | async"
+            [dataLength]="dataLength | async"
+            [selectedData]="selectedData | async"
+            (getSearchData)="getSearchData($event)"
+            (addSelectedData)="addSearchData($event)"
+            (deleteSelectedData)="deleteSearchData($event)"
+            [processWip]="processWip | async"
+            [processDone]="processDone | async"
+            [processId]="processId | async"
+            (executeProcess)="executeProcess($event)">
+        </app-datatable-tab>
+    </div>
+</div>
+<div *ngIf="dataLengthIsLoaded | async" class="row mt-5 justify-content-between">
+    <div class="col">
+        <a routerLink="/search/output/{{ datasetName | async }}" [queryParams]="queryParams | async"
+            class="btn btn-outline-secondary">
+            <span class="fas fa-arrow-left"></span> Output
+        </a>
+    </div>
+</div>
diff --git a/client/src/app/instance/search/containers/result.component.ts b/client/src/app/instance/search/containers/result.component.ts
new file mode 100644
index 00000000..a51c05d9
--- /dev/null
+++ b/client/src/app/instance/search/containers/result.component.ts
@@ -0,0 +1,162 @@
+/**
+ * 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, OnInit, OnDestroy } from '@angular/core';
+
+import { Store } from '@ngrx/store';
+import { Observable } from 'rxjs';
+
+import { Criterion, SearchQueryParams, Pagination } from '../../store/models';
+import { Dataset, CriteriaFamily, OutputFamily, Attribute, OutputCategory } from 'src/app/metamodel/models';
+import * as instanceSelector from 'src/app/metamodel/selectors/instance.selector';
+import * as datasetActions from 'src/app/metamodel/actions/dataset.actions';
+import * as datasetSelector from 'src/app/metamodel/selectors/dataset.selector';
+import * as attributeActions from 'src/app/metamodel/actions/attribute.actions';
+import * as attributeSelector from 'src/app/metamodel/selectors/attribute.selector';
+import * as criteriaFamilyActions from 'src/app/metamodel/actions/criteria-family.actions';
+import * as criteriaFamilySelector from 'src/app/metamodel/selectors/criteria-family.selector';
+import * as outputFamilyActions from 'src/app/metamodel/actions/output-family.actions';
+import * as outputFamilySelector from 'src/app/metamodel/selectors/output-family.selector';
+import * as outputCategoryActions from 'src/app/metamodel/actions/output-category.actions';
+import * as outputCategorySelector from 'src/app/metamodel/selectors/output-category.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';
+
+@Component({
+    selector: 'app-result',
+    templateUrl: 'result.component.html'
+})
+/**
+ * @class
+ * @classdesc Search result container.
+ *
+ * @implements OnInit
+ * @implements OnDestroy
+ */
+export class ResultComponent implements OnInit, OnDestroy {
+    public datasetSelected: Observable<string>;
+    public instanceSelected: Observable<string>;
+    public datasetListIsLoading: Observable<boolean>;
+    public datasetListIsLoaded: Observable<boolean>;
+    public datasetList: Observable<Dataset[]>;
+    public attributeList: Observable<Attribute[]>;
+    public attributeListIsLoading: Observable<boolean>;
+    public attributeListIsLoaded: Observable<boolean>;
+    public criteriaFamilyList: Observable<CriteriaFamily[]>;
+    public criteriaFamilyListIsLoading: Observable<boolean>;
+    public criteriaFamilyListIsLoaded: Observable<boolean>;
+    public outputFamilyList: Observable<OutputFamily[]>;
+    public outputFamilyListIsLoading: Observable<boolean>;
+    public outputFamilyListIsLoaded: Observable<boolean>;
+    public outputCategoryList: Observable<OutputCategory[]>;
+    public outputCategoryListIsLoading: Observable<boolean>;
+    public outputCategoryListIsLoaded: Observable<boolean>;
+    public currentStep: Observable<string>;
+    public criteriaList: Observable<Criterion[]>;
+    public outputList: Observable<number[]>;
+    public queryParams: Observable<SearchQueryParams>;
+    public sampRegistered: Observable<boolean>;
+
+    constructor(private store: Store<{ }>) {
+        this.datasetSelected = store.select(datasetSelector.selectDatasetNameByRoute);
+        this.instanceSelected = store.select(instanceSelector.selectInstanceNameByRoute);
+        this.datasetListIsLoading = store.select(datasetSelector.selectDatasetListIsLoading);
+        this.datasetListIsLoaded = store.select(datasetSelector.selectDatasetListIsLoaded);
+        this.datasetList = store.select(datasetSelector.selectAllDatasets);
+        this.attributeList = store.select(attributeSelector.selectAllAttributes);
+        this.attributeListIsLoading = store.select(attributeSelector.selectAttributeListIsLoading);
+        this.attributeListIsLoaded = store.select(attributeSelector.selectAttributeListIsLoaded);
+        this.criteriaFamilyList = store.select(criteriaFamilySelector.selectAllCriteriaFamilies);
+        this.criteriaFamilyListIsLoading = store.select(criteriaFamilySelector.selectCriteriaFamilyListIsLoading);
+        this.criteriaFamilyListIsLoaded = store.select(criteriaFamilySelector.selectCriteriaFamilyListIsLoaded);
+        this.outputFamilyList = store.select(outputFamilySelector.selectAllOutputFamilies);
+        this.outputFamilyListIsLoading = store.select(outputFamilySelector.selectOutputFamilyListIsLoading);
+        this.outputFamilyListIsLoaded = store.select(outputFamilySelector.selectOutputFamilyListIsLoaded);
+        this.outputCategoryList = store.select(outputCategorySelector.selectAllOutputCategories);
+        this.outputCategoryListIsLoading = store.select(outputCategorySelector.selectOutputCategoryListIsLoading);
+        this.outputCategoryListIsLoaded = store.select(outputCategorySelector.selectOutputCategoryListIsLoaded);
+        this.currentStep = this.store.select(searchSelector.selectCurrentStep);
+        this.criteriaList = this.store.select(searchSelector.selectCriteriaList);
+        this.outputList = this.store.select(searchSelector.selectOutputList);
+        this.queryParams = this.store.select(searchSelector.selectQueryParams);
+        this.sampRegistered = this.store.select(sampSelector.selectRegistered);
+    }
+
+    ngOnInit() {
+        // Create a micro task that is processed after the current synchronous code
+        // This micro task prevent the expression has changed after view init error
+        Promise.resolve(null).then(() => this.store.dispatch(searchActions.changeStep({ step: 'result' })));
+        Promise.resolve(null).then(() => this.store.dispatch(searchActions.checkResult()));
+
+        this.store.dispatch(datasetActions.loadDatasetList());
+        this.datasetSelected.subscribe(dname => {
+            if (dname) {
+                this.store.dispatch(attributeActions.loadAttributeList());
+                this.store.dispatch(criteriaFamilyActions.loadCriteriaFamilyList());
+                this.store.dispatch(outputFamilyActions.loadOutputFamilyList());
+                this.store.dispatch(outputCategoryActions.loadOutputCategoryList());
+            }
+        });
+    }
+
+    sampRegister() {
+        this.store.dispatch(sampActions.register());
+    }
+
+    sampUnregister() {
+        this.store.dispatch(sampActions.unregister());
+    }
+
+    broadcastVotable(url: string) {
+        this.store.dispatch(sampActions.broadcastVotable({ url }));
+    }
+
+    /**
+     * Dispatches action to retrieve result number.
+     */
+    getDataLength(): void {
+        this.store.dispatch(searchActions.retrieveDataLength());
+    }
+
+    /**
+     * Dispatches action to retrieve data with the given pagination.
+     *
+     * @param {Pagination} pagination - The pagination parameters.
+     */
+    getSearchData(pagination: Pagination): void {
+        this.store.dispatch(searchActions.retrieveData({ pagination }));
+    }
+
+    /**
+     * Dispatches action to add the given data ID to the selected data.
+     *
+     * @param  {number | string} id - The data ID to add to the data selection.
+     */
+    addSearchData(id: number | string): void {
+        this.store.dispatch(searchActions.addSelectedData({ id }));
+    }
+
+    /**
+     * Dispatches action to remove the given data ID to the selected data.
+     *
+     * @param  {number | string} id - The data ID to remove to the data selection.
+     */
+    deleteSearchData(id: number | string): void {
+        this.store.dispatch(searchActions.deleteSelectedData({ id }));
+    }
+
+    /**
+     * Dispatches action to destroy search results.
+     */
+    ngOnDestroy() {
+        this.store.dispatch(searchActions.destroyResults());
+    }
+}
diff --git a/client/src/app/instance/search/search-routing.module.ts b/client/src/app/instance/search/search-routing.module.ts
index af0a1956..4ad9aa39 100644
--- a/client/src/app/instance/search/search-routing.module.ts
+++ b/client/src/app/instance/search/search-routing.module.ts
@@ -13,6 +13,7 @@ import { Routes, RouterModule } from '@angular/router';
 import { SearchComponent } from './search.component';
 import { DatasetComponent } from './containers/dataset.component';
 import { CriteriaComponent } from './containers/criteria.component';
+import { OutputComponent } from './containers/output.component';
 
 const routes: Routes = [
     {
@@ -20,7 +21,8 @@ const routes: Routes = [
             { path: '', redirectTo: 'dataset', pathMatch: 'full' },
             { path: 'dataset', component: DatasetComponent },
             { path: 'dataset/:dname', component: DatasetComponent },
-            { path: 'criteria/:dname', component: CriteriaComponent }
+            { path: 'criteria/:dname', component: CriteriaComponent },
+            { path: 'output/:dname', component: OutputComponent }
         ]
     }
 ];
@@ -34,5 +36,6 @@ export class SearchRoutingModule { }
 export const routedComponents = [
     SearchComponent,
     DatasetComponent,
-    CriteriaComponent
+    CriteriaComponent,
+    OutputComponent
 ];
diff --git a/client/src/app/instance/store/actions/search.actions.ts b/client/src/app/instance/store/actions/search.actions.ts
index 8ce3efcd..fb5c3580 100644
--- a/client/src/app/instance/store/actions/search.actions.ts
+++ b/client/src/app/instance/store/actions/search.actions.ts
@@ -9,7 +9,7 @@
 
 import { createAction, props } from '@ngrx/store';
 
-import { Criterion } from '../models';
+import { Criterion, Pagination } from '../models';
 
 export const changeStep = createAction('[Search] Change Step', props<{ step: string }>());
 export const checkCriteria = createAction('[Search] Check Criteria');
@@ -19,4 +19,13 @@ export const updateCriteriaList = createAction('[Search] Update Criteria List',
 export const addCriterion = createAction('[Search] Add Criterion', props<{ criterion: Criterion }>());
 export const deleteCriterion = createAction('[Search] Delete Criterion', props<{ idCriterion: number }>());
 export const updateOutputList = createAction('[Search] Update Output List', props<{ outputList: number[] }>());
+export const retrieveDataLength = createAction('[Search] Retrieve Data Length');
+export const retrieveDataLengthSuccess = createAction('[Search] Retrieve Data Length Success', props<{ length: number }>());
+export const retrieveDataLengthFail = createAction('[Search] Retrieve Data Length Fail');
+export const retrieveData = createAction('[Search] Retrieve Data', props<{ pagination: Pagination }>());
+export const retrieveDataSuccess = createAction('[Search] Retrieve Data Success', props<{ data: any[] }>());
+export const retrieveDataFail = createAction('[Search] Retrieve Data Fail');
+export const addSelectedData = createAction('[Search] Add Selected Data', props<{ id: number | string }>());
+export const deleteSelectedData = createAction('[Search] Delete Selected Data', props<{ id: number | string }>());
+export const destroyResults = createAction('[Search] Destroy Results');
 export const resetSearch = createAction('[Search] Reset Search');
diff --git a/client/src/app/instance/store/models/index.ts b/client/src/app/instance/store/models/index.ts
index 6429e953..b5c31bfe 100644
--- a/client/src/app/instance/store/models/index.ts
+++ b/client/src/app/instance/store/models/index.ts
@@ -1,3 +1,4 @@
 export * from './criterion.model';
 export * from './search-query-params.model';
 export * from './criterion';
+export * from './pagination.model';
diff --git a/client/src/app/instance/store/models/pagination.model.ts b/client/src/app/instance/store/models/pagination.model.ts
new file mode 100644
index 00000000..b3e3b00b
--- /dev/null
+++ b/client/src/app/instance/store/models/pagination.model.ts
@@ -0,0 +1,31 @@
+/**
+ * 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.
+ */
+
+/**
+ * Interface for datatable pagination.
+ *
+ * @interface Pagination
+ */
+ export interface Pagination {
+    dname: string;
+    page: number;
+    nbItems: number;
+    sortedCol: number;
+    order: PaginationOrder
+}
+
+/**
+ * Enum for PaginationOrder values.
+ * @readonly
+ * @enum {string}
+ */
+export enum PaginationOrder {
+    a = 'a',
+    d = 'd'
+}
diff --git a/client/src/app/instance/store/reducers/search.reducer.ts b/client/src/app/instance/store/reducers/search.reducer.ts
index 2f1b0458..96608d3b 100644
--- a/client/src/app/instance/store/reducers/search.reducer.ts
+++ b/client/src/app/instance/store/reducers/search.reducer.ts
@@ -78,6 +78,19 @@ export const searchReducer = createReducer(
         ...state,
         outputList
     })),
+    on(searchActions.addSelectedData, (state, { id }) => ({
+        ...state,
+        selectedData: [...state.selectedData, id]
+    })),
+    on(searchActions.deleteSelectedData, (state, { id }) => ({
+        ...state,
+        selectedData: [...state.selectedData.filter(d => d !== id)]
+    })),
+    on(searchActions.destroyResults, state => ({
+        ...state,
+        searchData: [],
+        dataLength: null
+    })),
     on(searchActions.resetSearch, () => ({
         ...initialState
     }))
diff --git a/client/src/styles.scss b/client/src/styles.scss
index 21170c7e..89e28473 100644
--- a/client/src/styles.scss
+++ b/client/src/styles.scss
@@ -83,3 +83,7 @@ input.ng-invalid, select.ng-invalid, textarea.ng-invalid {
 .pointer {
     cursor: pointer;
 }
+
+.disabled {
+    cursor: not-allowed !important;
+}
-- 
GitLab