From 3bd3ed251e850d805d3650f6b0d57c271e991f60 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fran=C3=A7ois=20Agneray?= <francois.agneray@lam.fr>
Date: Fri, 9 Jul 2021 22:28:26 +0200
Subject: [PATCH] Cone-search => WIP

---
 .../components/progress-bar.component.html    |   2 +-
 .../components/result/download.component.html |   4 +-
 .../components/result/download.component.ts   |  17 +-
 .../search/components/result/index.ts         |  10 +-
 .../components/result/reminder.component.html |  78 ++++++++++
 .../components/result/reminder.component.scss |  21 +++
 .../components/result/reminder.component.ts   | 122 +++++++++++++++
 .../components/result/samp.component.html     |  32 ++++
 .../components/result/samp.component.ts       |  47 ++++++
 .../result/url-display.component.html         |  29 ++++
 .../result/url-display.component.ts           |  80 ++++++++++
 .../search/components/summary.component.ts    |   4 +-
 .../search/containers/result.component.html   |  42 ++---
 .../search/containers/result.component.ts     |   5 +-
 .../cone-search/cone-search.component.html    |  47 ++++++
 .../cone-search/cone-search.component.ts      |  32 ++++
 .../store/actions/cone-search.actions.ts      |   0
 .../instance/store/effects/search.effects.ts  |  71 ++-------
 .../instance/store/models/criterion.model.ts  | 145 ++++++++++++------
 .../instance/store/reducers/search.reducer.ts |  18 ++-
 .../store/selectors/search.selector.ts        |   4 +-
 client/src/app/shared/shared.module.ts        |   3 +
 22 files changed, 662 insertions(+), 151 deletions(-)
 create mode 100644 client/src/app/instance/search/components/result/reminder.component.html
 create mode 100644 client/src/app/instance/search/components/result/reminder.component.scss
 create mode 100644 client/src/app/instance/search/components/result/reminder.component.ts
 create mode 100644 client/src/app/instance/search/components/result/samp.component.html
 create mode 100644 client/src/app/instance/search/components/result/samp.component.ts
 create mode 100644 client/src/app/instance/search/components/result/url-display.component.html
 create mode 100644 client/src/app/instance/search/components/result/url-display.component.ts
 create mode 100644 client/src/app/instance/shared/components/cone-search/cone-search.component.html
 create mode 100644 client/src/app/instance/shared/components/cone-search/cone-search.component.ts
 create mode 100644 client/src/app/instance/store/actions/cone-search.actions.ts

diff --git a/client/src/app/instance/search/components/progress-bar.component.html b/client/src/app/instance/search/components/progress-bar.component.html
index e883f331..4575d0e8 100644
--- a/client/src/app/instance/search/components/progress-bar.component.html
+++ b/client/src/app/instance/search/components/progress-bar.component.html
@@ -11,7 +11,7 @@
     </div>
     <ul class="nav nav-pills">
         <li class="nav-item checked" [ngClass]="{'active': currentStep === 'dataset'}">
-            <a class="nav-link" routerLink="/instance/{{ instanceSelected }}/search/dataset/{{ datasetSelected }}" data-toggle="tab">
+            <a class="nav-link" routerLink="/instance/{{ instanceSelected }}/search/dataset/{{ datasetSelected }}" [queryParams]="queryParams" data-toggle="tab">
                 <div class="icon-circle">
                     <span class="fas fa-book"></span>
                 </div>
diff --git a/client/src/app/instance/search/components/result/download.component.html b/client/src/app/instance/search/components/result/download.component.html
index b725623a..655ee2e2 100644
--- a/client/src/app/instance/search/components/result/download.component.html
+++ b/client/src/app/instance/search/components/result/download.component.html
@@ -1,6 +1,4 @@
-<app-spinner *ngIf="(dataLengthIsLoading)"></app-spinner>
-
-<div *ngIf="(dataLengthIsLoaded)" class="jumbotron mb-4 py-4">
+<div class="jumbotron mb-4 py-4">
     <div class="lead">
         Dataset <span class="bold">{{ getDatasetLabel() }}</span> selected with <span class="bold">{{ dataLength }}</span> objects found.
     </div>
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 5c58b932..aedba808 100644
--- a/client/src/app/instance/search/components/result/download.component.ts
+++ b/client/src/app/instance/search/components/result/download.component.ts
@@ -7,9 +7,9 @@
  * file that was distributed with this source code.
  */
 
-import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
+import { Component, EventEmitter, Input, Output } from '@angular/core';
 
-import { Criterion, getCriterionStr } from '../../../store/models';
+import { Criterion, criterionToString } from '../../../store/models';
 import { Dataset } from 'src/app/metamodel/models';
 import { getHost as host } from 'src/app/shared/utils';
 // import { ConeSearch } from '../../../shared/cone-search/store/model';
@@ -25,24 +25,17 @@ import { getHost as host } from 'src/app/shared/utils';
  *
  * @implements OnInit
  */
-export class DownloadComponent implements OnInit {
+export class DownloadComponent {
     @Input() datasetSelected: string;
     @Input() datasetList: Dataset[];
     @Input() criteriaList: Criterion[];
     @Input() outputList: number[];
     @Input() dataLength: number;
-    @Input() dataLengthIsLoading: boolean;
-    @Input() dataLengthIsLoaded: boolean;
     //@Input() isConeSearchAdded: boolean;
     //@Input() coneSearch: ConeSearch;
     @Input() sampRegistered: boolean;
-    @Output() getDataLength: EventEmitter<null> = new EventEmitter();
     @Output() broadcast: EventEmitter<string> = new EventEmitter();
 
-    ngOnInit() {
-        this.getDataLength.emit();
-    }
-
     /**
      * Returns dataset label.
      *
@@ -85,7 +78,7 @@ export class DownloadComponent implements OnInit {
     getUrl(format: string): string {
         let query: string = host() + '/search/' + this.datasetSelected + '?a=' + this.outputList.join(';');
         if (this.criteriaList.length > 0) {
-            query += '&c=' + this.criteriaList.map(criterion => getCriterionStr(criterion)).join(';');
+            query += '&c=' + this.criteriaList.map(criterion => criterionToString(criterion)).join(';');
         }
         // if (this.isConeSearchAdded) {
         //     query += '&cs=' + this.coneSearch.ra + ':' + this.coneSearch.dec + ':' + this.coneSearch.radius;
@@ -97,7 +90,7 @@ export class DownloadComponent implements OnInit {
     getUrlArchive(): string {
         let query: string = host() + '/archive/' + this.datasetSelected + '?a=' + this.outputList.join(';');
         if (this.criteriaList.length > 0) {
-            query += '&c=' + this.criteriaList.map(criterion => getCriterionStr(criterion)).join(';');
+            query += '&c=' + this.criteriaList.map(criterion => criterionToString(criterion)).join(';');
         }
         // if (this.isConeSearchAdded) {
         //     query += '&cs=' + this.coneSearch.ra + ':' + this.coneSearch.dec + ':' + this.coneSearch.radius;
diff --git a/client/src/app/instance/search/components/result/index.ts b/client/src/app/instance/search/components/result/index.ts
index 2d5a35f2..68174bc2 100644
--- a/client/src/app/instance/search/components/result/index.ts
+++ b/client/src/app/instance/search/components/result/index.ts
@@ -1,5 +1,11 @@
-import { DownloadComponent } from "./download.component";
+import { DownloadComponent } from './download.component';
+import { ReminderComponent } from './reminder.component';
+import { SampComponent } from './samp.component';
+import { UrlDisplayComponent } from './url-display.component';
 
 export const resultComponents = [
-    DownloadComponent
+    DownloadComponent,
+    ReminderComponent,
+    SampComponent,
+    UrlDisplayComponent
 ];
diff --git a/client/src/app/instance/search/components/result/reminder.component.html b/client/src/app/instance/search/components/result/reminder.component.html
new file mode 100644
index 00000000..d76e57d7
--- /dev/null
+++ b/client/src/app/instance/search/components/result/reminder.component.html
@@ -0,0 +1,78 @@
+<accordion *ngIf="isSummaryActivated()" [isAnimated]="true">
+    <accordion-group #ag [isOpen]="isSummaryOpened()" [panelClass]="'custom-accordion'" class="my-2">
+        <button class="btn btn-link btn-block clearfix" accordion-heading>
+            <span class="pull-left float-left">
+                Search summary
+                &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>
+        <div>
+            <tabset [justified]="true">
+                <tab>
+                    <ng-template tabHeading>
+                        Criteria <span class="badge badge-pill badge-secondary">{{ nbCriteria() }}</span>
+                    </ng-template>
+                    <div class="tab-content container-fluid pt-3">
+                        <div *ngIf="nbCriteria() === 0" class="text-center font-weight-bold pt-5">
+                            No selected criteria
+                        </div>
+
+                        <div *ngIf="nbCriteria() > 0" class="row">
+                            <!-- <div *ngIf="isConeSearchAdded" class="col-12 col-md-6 col-xl-4 pb-3">
+                                <span class="title">Cone search</span>
+                                <ul class="list-unstyled pl-3">
+                                    <li>RA = {{ coneSearch.ra }}°</li>
+                                    <li>DEC = {{ coneSearch.dec }}°</li>
+                                    <li>radius = {{ coneSearch.radius }} arcsecond</li>
+                                </ul>
+                            </div> -->
+
+                            <ng-container *ngFor="let family of criteriaFamilyList">
+                                <ng-container *ngIf="criteriaByFamily(family.id).length > 0">
+                                    <div class="col-12 col-md-6 col-xl-4 pb-3">
+                                        <span class="title">{{ family.label }}</span>
+                                        <ul class="list-unstyled pl-3">
+                                            <li *ngFor="let criterion of criteriaByFamily(family.id)">
+                                                {{ getAttribute(criterion.id).form_label }} {{ printCriterion(criterion) }}
+                                            </li>
+                                        </ul>
+                                    </div>
+                                </ng-container>
+                            </ng-container>
+                        </div>
+                    </div>
+                </tab>
+                <tab>
+                    <ng-template tabHeading>
+                        Outputs <span class="badge badge-pill badge-secondary">{{ outputList.length }}</span>
+                    </ng-template>
+                    <div class="tab-content container-fluid pt-3">
+                        <div class="row">
+                            <ng-container *ngFor="let family of outputFamilyList">
+                                <ng-container *ngFor="let category of categoryListByFamily(family.id)">
+                                    <ng-container *ngIf="outputListByCategory(category.id).length > 0">
+                                        <div class="col-12 col-md-6 col-lg-4 col-xl-3 pb-3">
+                                            <span class="title">{{ family.label }}</span><br>
+                                            <span class="title pl-3">{{ category.label }}</span>
+                                            <ul class="list-unstyled pl-5">
+                                                <li *ngFor="let output of outputListByCategory(category.id)">
+                                                    {{ getAttribute(output).form_label }}
+                                                </li>
+                                            </ul>
+                                        </div>
+                                    </ng-container>
+                                </ng-container>
+                            </ng-container>
+                        </div>
+                    </div>
+                </tab>
+            </tabset>
+        </div>
+    </accordion-group>
+</accordion>
diff --git a/client/src/app/instance/search/components/result/reminder.component.scss b/client/src/app/instance/search/components/result/reminder.component.scss
new file mode 100644
index 00000000..2ae066f5
--- /dev/null
+++ b/client/src/app/instance/search/components/result/reminder.component.scss
@@ -0,0 +1,21 @@
+/**
+ * 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.
+ */
+
+.tab-content {
+    height: 250px;
+    border-bottom: #dee2e6 solid 1px;
+    border-left: #dee2e6 solid 1px;
+    border-right: #dee2e6 solid 1px;
+    overflow-y: auto;
+}
+
+.title {
+    color: #6c757d;
+    font-size: 90%;
+}
diff --git a/client/src/app/instance/search/components/result/reminder.component.ts b/client/src/app/instance/search/components/result/reminder.component.ts
new file mode 100644
index 00000000..9961ece0
--- /dev/null
+++ b/client/src/app/instance/search/components/result/reminder.component.ts
@@ -0,0 +1,122 @@
+/**
+ * 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 { Criterion, ConeSearch, getPrettyCriterion } from 'src/app/instance/store/models';
+import { Dataset, Attribute, CriteriaFamily, OutputFamily, OutputCategory } from 'src/app/metamodel/models';
+
+@Component({
+    selector: 'app-reminder',
+    templateUrl: 'reminder.component.html',
+    styleUrls: ['reminder.component.scss']
+})
+/**
+ * @class
+ * @classdesc Search result reminder component.
+ */
+export class ReminderComponent {
+    @Input() datasetSelected: string;
+    @Input() datasetList: Dataset[];
+    @Input() attributeList: Attribute[];
+    @Input() criteriaFamilyList: CriteriaFamily[];
+    @Input() outputFamilyList: OutputFamily[];
+    @Input() outputCategoryList: OutputCategory[];
+    // @Input() isConeSearchAdded: boolean;
+    // @Input() coneSearch: ConeSearch;
+    @Input() criteriaList: Criterion[];
+    @Input() outputList: number[];
+
+    isSummaryActivated(): boolean {
+        const dataset = this.datasetList.find(dataset => dataset.name === this.datasetSelected);
+        return dataset.config.summary.summary_enabled;
+    }
+
+    isSummaryOpened(): boolean {
+        const dataset = this.datasetList.find(dataset => dataset.name === this.datasetSelected);
+        return dataset.config.summary.summary_opened;
+    }
+
+    /**
+     * Returns total of added criteria.
+     *
+     * @return number
+     */
+    nbCriteria(): number {
+        // if (this.isConeSearchAdded) {
+        //     return this.criteriaList.length + 1;
+        // }
+        return this.criteriaList.length;
+    }
+
+    /**
+     * Returns criteria list for the given criteria family ID.
+     *
+     * @param  {number} idFamily - The criteria family ID.
+     *
+     * @return Criterion[]
+     */
+    criteriaByFamily(idFamily: number): Criterion[] {
+        const attributeListByFamily: Attribute[] = this.attributeList
+            .filter(attribute => attribute.id_criteria_family === idFamily);
+        return this.criteriaList
+            .filter(criterion => attributeListByFamily.includes(
+                this.attributeList.find(attribute => attribute.id === criterion.id))
+            );
+    }
+
+    /**
+     * Returns attribute for the given attribute ID.
+     *
+     * @param  {number} id - The attribute ID.
+     *
+     * @return Attribute
+     */
+    getAttribute(id: number): Attribute {
+        return this.attributeList.find(attribute => attribute.id === id);
+    }
+
+    /**
+     * Returns criterion pretty printed.
+     *
+     * @param  {Criterion} criterion - The criterion.
+     *
+     * @return string
+     */
+    printCriterion(criterion: Criterion): string {
+        return getPrettyCriterion(criterion);
+    }
+
+    /**
+     * Returns output category list for the given criteria family ID.
+     *
+     * @param  {number} idFamily - The criteria family ID.
+     *
+     * @return OutputCategory[]
+     */
+    categoryListByFamily(idFamily: number): OutputCategory[] {
+        return this.outputCategoryList.filter(outputCategory => outputCategory.id_output_family === idFamily);
+    }
+
+    /**
+     * Returns output list for the given category ID.
+     *
+     * @param  {number} idCategory - The output category ID.
+     *
+     * @return number[]
+     */
+    outputListByCategory(idCategory: number): number[] {
+        const attributeListByCategory: Attribute[] = this.attributeList
+            .filter(attribute => attribute.id_output_category === idCategory);
+        return this.outputList
+            .filter(output => attributeListByCategory.includes(
+                this.attributeList.find(attribute => attribute.id === output))
+            );
+    }
+}
diff --git a/client/src/app/instance/search/components/result/samp.component.html b/client/src/app/instance/search/components/result/samp.component.html
new file mode 100644
index 00000000..62b9aba7
--- /dev/null
+++ b/client/src/app/instance/search/components/result/samp.component.html
@@ -0,0 +1,32 @@
+<accordion *ngIf="isSampActivated()" [isAnimated]="true">
+    <accordion-group #ag [isOpen]="isSampOpened()" [panelClass]="'custom-accordion'" class="my-2">
+        <button class="btn btn-link btn-block clearfix" accordion-heading>
+            <span class="pull-left float-left">
+                <span [style.color]="getColor()">
+                    <i class="fas fa-circle"></i>
+                </span> SAMP access
+                &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>
+        <div>
+            <div class="row">
+                <p *ngIf="!sampRegistered" class="lead">
+                    You are not connected to a SAMP-hub 
+                </p>
+                <p *ngIf="sampRegistered" class="lead">
+                    You are connected to a SAMP-hub 
+                </p>
+            </div>
+            <div class="row">
+                <button *ngIf="!sampRegistered" (click)="sampRegister.emit()" class="btn btn-outline-primary">Try to register</button>
+                <button *ngIf="sampRegistered" (click)="sampUnregister.emit()" class="btn btn-outline-primary">Unregister</button>
+            </div>
+        </div>
+    </accordion-group>
+</accordion>
diff --git a/client/src/app/instance/search/components/result/samp.component.ts b/client/src/app/instance/search/components/result/samp.component.ts
new file mode 100644
index 00000000..fb96f57e
--- /dev/null
+++ b/client/src/app/instance/search/components/result/samp.component.ts
@@ -0,0 +1,47 @@
+/**
+ * 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, ChangeDetectionStrategy, Output, EventEmitter } from '@angular/core';
+
+import { Dataset } from 'src/app/metamodel/models';
+
+@Component({
+    selector: 'app-samp',
+    templateUrl: 'samp.component.html',
+    changeDetection: ChangeDetectionStrategy.OnPush
+})
+/**
+ * @class
+ * @classdesc Samp component.
+ */
+export class SampComponent {
+    @Input() datasetSelected: string;
+    @Input() datasetList: Dataset[];
+    @Input() sampRegistered: boolean;
+    @Output() sampRegister: EventEmitter<{}> = new EventEmitter();
+    @Output() sampUnregister: EventEmitter<{}> = new EventEmitter();
+
+    isSampActivated(): boolean {
+        const dataset = this.datasetList.find(dataset => dataset.name === this.datasetSelected);
+        return dataset.config.samp.samp_enabled;
+    }
+
+    isSampOpened(): boolean {
+        const dataset = this.datasetList.find(dataset => dataset.name === this.datasetSelected);
+        return dataset.config.samp.samp_opened;
+    }
+
+    getColor(): string {
+        if (this.sampRegistered) {
+            return 'green';
+        } else {
+            return 'red';
+        }
+    }
+}
diff --git a/client/src/app/instance/search/components/result/url-display.component.html b/client/src/app/instance/search/components/result/url-display.component.html
new file mode 100644
index 00000000..109b122d
--- /dev/null
+++ b/client/src/app/instance/search/components/result/url-display.component.html
@@ -0,0 +1,29 @@
+<accordion *ngIf="urlDisplayEnabled()" [isAnimated]="true">
+    <accordion-group #ag [isOpen]="urlDisplayOpened()" [panelClass]="'custom-accordion'" class="my-2">
+        <button class="btn btn-link btn-block clearfix" accordion-heading>
+            <span class="pull-left float-left">
+                Direct link to the result (JSON)
+                &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>
+        <div>
+            <div class="row">
+                <div class="col">
+                    <a target="_blank" [href]="getUrl()">{{ getUrl() }}</a>
+                </div>
+                <div class="col-2 align-self-center text-center">
+                    <button class="btn btn-sm btn-outline-primary" (click)="copyToClipboard()"
+                        title="Copy url to clipboard">
+                        COPY
+                    </button>
+                </div>
+            </div>
+        </div>
+    </accordion-group>
+</accordion>
diff --git a/client/src/app/instance/search/components/result/url-display.component.ts b/client/src/app/instance/search/components/result/url-display.component.ts
new file mode 100644
index 00000000..469bc9b4
--- /dev/null
+++ b/client/src/app/instance/search/components/result/url-display.component.ts
@@ -0,0 +1,80 @@
+/**
+ * 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, ChangeDetectionStrategy } from '@angular/core';
+
+import { ToastrService } from 'ngx-toastr';
+
+import { Criterion, ConeSearch, criterionToString } from 'src/app/instance/store/models';
+import { Dataset } from 'src/app/metamodel/models';
+import { getHost as host } from 'src/app/shared/utils';
+
+@Component({
+    selector: 'app-url-display',
+    templateUrl: 'url-display.component.html',
+    changeDetection: ChangeDetectionStrategy.OnPush
+})
+/**
+ * @class
+ * @classdesc Search result URL display component.
+ */
+export class UrlDisplayComponent {
+    @Input() datasetSelected: string;
+    @Input() datasetList: Dataset[];
+    // @Input() isConeSearchAdded: boolean;
+    // @Input() coneSearch: ConeSearch;
+    @Input() criteriaList: Criterion[];
+    @Input() outputList: number[];
+
+    constructor(private toastr: ToastrService) { }
+
+    /**
+     * Checks if URL display is enabled.
+     *
+     * @return boolean
+     */
+    urlDisplayEnabled(): boolean {
+        const config = this.datasetList.find(d => d.name === this.datasetSelected).config;
+        return config.server_link.server_link_enabled;
+    }
+
+    urlDisplayOpened(): boolean {
+        const config = this.datasetList.find(d => d.name === this.datasetSelected).config;
+        return config.server_link.server_link_opened;
+    }
+
+    /**
+     * Returns API URL to get data with user parameters.
+     *
+     * @return string
+     */
+    getUrl(): string {
+        let query: string = host() + '/search/' + this.datasetSelected + '?a=' + this.outputList.join(';');
+        if (this.criteriaList.length > 0) {
+            query += '&c=' + this.criteriaList.map(criterion => criterionToString(criterion)).join(';');
+        }
+        // if (this.isConeSearchAdded) {
+        //     query += '&cs=' + this.coneSearch.ra + ':' + this.coneSearch.dec + ':' + this.coneSearch.radius;
+        // }
+        return query;
+    }
+
+    /**
+     * Copies API URL to user clipboard.
+     */
+    copyToClipboard(): void {
+        const selBox = document.createElement('textarea');
+        selBox.value = this.getUrl();
+        document.body.appendChild(selBox);
+        selBox.select();
+        document.execCommand('copy');
+        document.body.removeChild(selBox);
+        this.toastr.success('Copied');
+    }
+}
diff --git a/client/src/app/instance/search/components/summary.component.ts b/client/src/app/instance/search/components/summary.component.ts
index 6f7db465..dfa03f44 100644
--- a/client/src/app/instance/search/components/summary.component.ts
+++ b/client/src/app/instance/search/components/summary.component.ts
@@ -9,7 +9,7 @@
 
 import { ChangeDetectionStrategy, Component, Input, ViewEncapsulation } from '@angular/core';
 
-import { Criterion, SearchQueryParams, printCriterion } from '../../store/models';
+import { Criterion, SearchQueryParams, getPrettyCriterion } from '../../store/models';
 import { Attribute, Dataset, CriteriaFamily, OutputFamily, OutputCategory } from 'src/app/metamodel/models';
 // import { ConeSearch } from '../../shared/cone-search/store/model';
 
@@ -80,7 +80,7 @@ export class SummaryComponent {
      * @return string
      */
     printCriterion(criterion: Criterion): string {
-        return printCriterion(criterion);
+        return getPrettyCriterion(criterion);
     }
 
     /**
diff --git a/client/src/app/instance/search/containers/result.component.html b/client/src/app/instance/search/containers/result.component.html
index 009f430b..592300d2 100644
--- a/client/src/app/instance/search/containers/result.component.html
+++ b/client/src/app/instance/search/containers/result.component.html
@@ -2,14 +2,23 @@
     || (attributeListIsLoading | async) 
     || (criteriaFamilyListIsLoading | async) 
     || (outputFamilyListIsLoading | async)
-    || (outputCategoryListIsLoading | async)">
+    || (outputCategoryListIsLoading | async)
+    || (dataLengthIsLoading | async)">
 </app-spinner>
 
+<div *ngIf="(dataLength | async) < 1" class="jumbotron mb-4 py-4">
+    <div class="lead">
+        No data found for this search
+    </div>
+</div>
+
 <div *ngIf="(datasetListIsLoaded | async)
     && (attributeListIsLoaded | async) 
     && (criteriaFamilyListIsLoaded | async) 
     && (outputFamilyListIsLoaded | async)
-    && (outputCategoryListIsLoaded | async)" class="row mt-4">
+    && (outputCategoryListIsLoaded | async)
+    && (dataLengthIsLoaded | async)
+    && (dataLength | async) > 0" class="row mt-4">
     <div class="col-12">
         <app-download
             [datasetSelected]="datasetSelected | async"
@@ -17,42 +26,33 @@
             [criteriaList]="criteriaList | async"
             [outputList]="outputList | async"
             [dataLength]="dataLength | async"
-            [dataLengthIsLoading]="dataLengthIsLoading | async"
-            [dataLengthIsLoaded]="dataLengthIsLoaded | async"
             [sampRegistered]="sampRegistered | async"
-            (getDataLength)="getDataLength()"
             (broadcast)="broadcastVotable($event)">
         </app-download>
-        <!-- <app-reminder
-            [datasetName]="datasetName | async"
+        <app-reminder
+            [datasetSelected]="datasetSelected | async"
             [datasetList]="datasetList | async"
-            [datasetAttributeList]="attributeList | async"
-            [dataLengthIsLoaded]="dataLengthIsLoaded | async"
-            [isConeSearchAdded]="isConeSearchAdded | async"
-            [coneSearch]="coneSearch | async"
+            [attributeList]="attributeList | async"
             [criteriaFamilyList]="criteriaFamilyList | async"
-            [criteriaList]="criteriaList | async"
             [outputFamilyList]="outputFamilyList | async"
-            [categoryList]="categoryList | async"
+            [outputCategoryList]="outputCategoryList | async"
+            [criteriaList]="criteriaList | async"
             [outputList]="outputList | async">
         </app-reminder>
         <app-samp
-            [datasetName]="datasetName | async"
+            [datasetSelected]="datasetSelected | async"
             [datasetList]="datasetList | async"
             [sampRegistered]="sampRegistered | async"
             (sampRegister)="sampRegister()"
             (sampUnregister)="sampUnregister()">
         </app-samp>
-        <app-result-url-display
+        <app-url-display
+            [datasetSelected]="datasetSelected | async"
             [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
+        </app-url-display>
+        <!-- <app-cone-search-plot-tab
             [datasetName]="datasetName | async"
             [datasetList]="datasetList | async"
             [datasetAttributeList]="attributeList | async"
diff --git a/client/src/app/instance/search/containers/result.component.ts b/client/src/app/instance/search/containers/result.component.ts
index 0b94c8b5..0d4ebb91 100644
--- a/client/src/app/instance/search/containers/result.component.ts
+++ b/client/src/app/instance/search/containers/result.component.ts
@@ -7,7 +7,7 @@
  * file that was distributed with this source code.
  */
 
-import { Component, OnInit, OnDestroy } from '@angular/core';
+import { Component } from '@angular/core';
 
 import { Store } from '@ngrx/store';
 import { Observable } from 'rxjs';
@@ -49,6 +49,7 @@ export class ResultComponent extends AbstractSearchComponent {
         // 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()));
+        Promise.resolve(null).then(() => this.store.dispatch(searchActions.retrieveDataLength()));
         super.ngOnInit();
     }
 
@@ -68,7 +69,7 @@ export class ResultComponent extends AbstractSearchComponent {
      * Dispatches action to retrieve result number.
      */
     getDataLength(): void {
-        this.store.dispatch(searchActions.retrieveDataLength());
+        // this.store.dispatch(searchActions.retrieveDataLength());
     }
 
     /**
diff --git a/client/src/app/instance/shared/components/cone-search/cone-search.component.html b/client/src/app/instance/shared/components/cone-search/cone-search.component.html
new file mode 100644
index 00000000..c0cf4330
--- /dev/null
+++ b/client/src/app/instance/shared/components/cone-search/cone-search.component.html
@@ -0,0 +1,47 @@
+<div class="row pb-4">
+    <div class="col">
+        <app-resolver
+            [resolverWip]="resolverWip | async"
+            [resolver]="resolver | async"
+            [disabled]="disabled"
+            (resolveName)="retrieveCoordinates($event)">
+        </app-resolver>
+    </div>
+</div>
+<div class="row">
+    <div class="col pb-4">
+        <app-ra
+            [coneSearch]="coneSearch | async"
+            [resolver]="resolver | async"
+            [unit]="unit"
+            [disabled]="disabled"
+            (updateConeSearch)="updateConeSearch($event)"
+            (deleteResolver)="deleteResolver()">
+        </app-ra>
+    </div>
+    <div class="col-auto p-0 align-self-center">
+        <button class="btn btn-outline-secondary"
+            [disabled]="disabled"
+            (click)="unit === 'degree' ? unit = 'hms' : unit = 'degree'"
+            title="Change unit">
+        <span class="fas fa-sync-alt"></span>
+        </button>
+    </div>
+    <div class="col">
+        <app-dec
+            [coneSearch]="coneSearch | async"
+            [resolver]="resolver | async"
+            [unit]="unit"
+            [disabled]="disabled"
+            (updateConeSearch)="updateConeSearch($event)"
+            (deleteResolver)="deleteResolver()">
+        </app-dec>
+    </div>
+    <div class="col-12">
+        <app-radius
+            [coneSearch]="coneSearch | async"
+            [disabled]="disabled"
+            (updateConeSearch)="updateConeSearch($event)">
+        </app-radius>
+    </div>
+</div>
diff --git a/client/src/app/instance/shared/components/cone-search/cone-search.component.ts b/client/src/app/instance/shared/components/cone-search/cone-search.component.ts
new file mode 100644
index 00000000..d506c318
--- /dev/null
+++ b/client/src/app/instance/shared/components/cone-search/cone-search.component.ts
@@ -0,0 +1,32 @@
+/**
+ * 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 } from '@angular/core';
+
+import { ConeSearch, Resolver } from 'src/app/instance/store/models';
+
+@Component({
+    selector: 'app-cone-search',
+    templateUrl: 'cone-search.component.html'
+})
+/**
+ * @class
+ * @classdesc Cone search container.
+ */
+export class ConeSearchComponent {
+    @Input() disabled: boolean = false;
+    @Input() resolverWip: boolean;
+    @Input() resolver: Resolver;
+    @Input() coneSearch: ConeSearch;
+    @Output() retrieveCoordinates: EventEmitter<string> = new EventEmitter();
+    @Output() updateConeSearch: EventEmitter<ConeSearch> = new EventEmitter();
+    @Output() deleteResolver: EventEmitter<{}> = new EventEmitter();
+
+    unit = 'degree';
+}
diff --git a/client/src/app/instance/store/actions/cone-search.actions.ts b/client/src/app/instance/store/actions/cone-search.actions.ts
new file mode 100644
index 00000000..e69de29b
diff --git a/client/src/app/instance/store/effects/search.effects.ts b/client/src/app/instance/store/effects/search.effects.ts
index f400e805..40e09a51 100644
--- a/client/src/app/instance/store/effects/search.effects.ts
+++ b/client/src/app/instance/store/effects/search.effects.ts
@@ -15,7 +15,7 @@ import { of } from 'rxjs';
 import { map, tap, mergeMap, catchError } from 'rxjs/operators';
 import { ToastrService } from 'ngx-toastr';
 
-import { getCriterionStr } from '../models';
+import { criterionToString, stringToCriterion } from '../models';
 import { SearchService } from '../services/search.service';
 import * as searchActions from '../actions/search.actions';
 import * as attributeActions from 'src/app/metamodel/actions/attribute.actions';
@@ -95,72 +95,17 @@ export class SearchEffects {
             mergeMap(([action, attributeList, criteriaList]) => {
                 let defaultCriteriaList = [];
                 if (criteriaList) {
+                    // Build criteria list with the URL query parameters (c)
                     defaultCriteriaList = criteriaList.split(';').map((c: string) => {
                         const params = c.split('::');
                         const attribute = attributeList.find(a => a.id === parseInt(params[0], 10));
-                        switch (attribute.search_type) {
-                            case 'field':
-                            case 'select':
-                            case 'datalist':
-                            case 'radio':
-                            case 'date':
-                            case 'date-time':
-                            case 'time':
-                                return { id: parseInt(params[0], 10), type: 'field', operator: params[1], value: params[2] };
-                            case 'list':
-                                return { id: parseInt(params[0], 10), type: 'list', values: params[2].split('|') };
-                            case 'between':
-                            case 'between-date':
-                                if (params[1] === 'bw') {
-                                    const bwValues = params[2].split('|');
-                                    return { id: parseInt(params[0], 10), type: 'between', min: bwValues[0], max: bwValues[1] };
-                                } else if (params[1] === 'gte') {
-                                    return { id: parseInt(params[0], 10), type: 'between', min: params[2], max: null };
-                                } else {
-                                    return { id: parseInt(params[0], 10), type: 'between', min: null, max: params[2] };
-                                }
-                            case 'select-multiple':
-                            case 'checkbox':
-                                const msValues = params[2].split('|');
-                                const options = attribute.options.filter(option => msValues.includes(option.value));
-                                return { id: parseInt(params[0], 10), type: 'multiple', options };
-                            case 'json':
-                                const [path, operator, value] = params[2].split('|');
-                                return { id: parseInt(params[0], 10), type: 'json', path, operator, value };
-                            default:
-                                return null;
-                        }
+                        return stringToCriterion(attribute, params);
                     });
                 } else {
+                    // Build default criteria list with the attribute list metamodel configuration
                     defaultCriteriaList = attributeList
                         .filter(attribute => attribute.id_criteria_family && attribute.search_type && attribute.min)
-                        .map(attribute => {
-                            switch (attribute.search_type) {
-                                case 'field':
-                                case 'select':
-                                case 'datalist':
-                                case 'radio':
-                                case 'date':
-                                case 'date-time':
-                                case 'time':
-                                    return { id: attribute.id, type: 'field', value: attribute.min.toString(), operator: attribute.operator };
-                                case 'list':
-                                    return { id: attribute.id, type: 'list', values: attribute.min.toString().split('|') };
-                                case 'between':
-                                case 'between-date':
-                                    return { id: attribute.id, type: 'between', min: attribute.min.toString(), max: attribute.max.toString() };
-                                case 'select-multiple':
-                                case 'checkbox':
-                                    const msValues = attribute.min.toString().split('|');
-                                    const options = attribute.options.filter(option => msValues.includes(option.value));
-                                    return { id: attribute.id, type: 'multiple', options };
-                                case 'json':
-                                    const [path, operator, value] = attribute.min.toString().split('|');
-                                    return { id: attribute.id, type: 'json', path, operator, value };
-                                default:
-                                    return null;
-                            }
-                        });
+                        .map(attribute => stringToCriterion(attribute));
                 }
                 return of(searchActions.updateCriteriaList({ criteriaList: defaultCriteriaList }));
             })
@@ -177,8 +122,10 @@ export class SearchEffects {
             mergeMap(([action, attributeList, outputList]) => {
                 let defaultOutputList = [];
                 if (outputList) {
+                    // Build output list with the URL query parameters (a)
                     defaultOutputList = outputList.split(';').map((o: string) => parseInt(o, 10));
                 } else {
+                    // Build default output list with the attribute list metamodel configuration
                     defaultOutputList = attributeList
                         .filter(attribute => attribute.selected && attribute.id_output_category)
                         .map(attribute => attribute.id);
@@ -198,7 +145,7 @@ export class SearchEffects {
             mergeMap(([action, datasetName, criteriaList]) => {
                 let query = datasetName + '?a=count';
                 if (criteriaList.length > 0) {
-                    query += '&c=' + criteriaList.map(criterion => getCriterionStr(criterion)).join(';');
+                    query += '&c=' + criteriaList.map(criterion => criterionToString(criterion)).join(';');
                 }
 
                 return this.searchService.retrieveDataLength(query)
@@ -228,7 +175,7 @@ export class SearchEffects {
             mergeMap(([action, datasetName, criteriaList, outputList]) => {
                 let query = datasetName + '?a=' + outputList.join(';');
                 if (criteriaList.length > 0) {
-                    query += '&c=' + criteriaList.map(criterion => getCriterionStr(criterion)).join(';');
+                    query += '&c=' + criteriaList.map(criterion => criterionToString(criterion)).join(';');
                 }
                 query += '&p=' + action.pagination.nbItems + ':' + action.pagination.page;
                 query += '&o=' + action.pagination.sortedCol + ':' + action.pagination.order;
diff --git a/client/src/app/instance/store/models/criterion.model.ts b/client/src/app/instance/store/models/criterion.model.ts
index d9e8c797..1b2f328a 100644
--- a/client/src/app/instance/store/models/criterion.model.ts
+++ b/client/src/app/instance/store/models/criterion.model.ts
@@ -7,13 +7,14 @@
  * file that was distributed with this source code.
  */
 
- import {
+import {
     BetweenCriterion,
     FieldCriterion,
     JsonCriterion,
     SelectMultipleCriterion,
     ListCriterion
 } from './criterion';
+import { Attribute } from 'src/app/metamodel/models';
 
 /**
  * @class
@@ -24,55 +25,17 @@ export interface Criterion {
     type: string;
 }
 
-/**
- * Returns pretty criterion string.
- *
- * @param  {Criterion} criterion - The criterion to pretty print.
- *
- * @return string
- *
- * @example
- * {{ printCriterion(criterion) }}
- */
- export const printCriterion = (criterion: Criterion): string => {
-    switch (criterion.type) {
-        case 'between':
-            const bw = criterion as BetweenCriterion;
-            if (bw.min === null) {
-                return '<= ' + bw.max;
-            } else if (bw.max === null) {
-                return '>= ' + bw.min;
-            } else {
-                return '∈ [' + bw.min + ';' + bw.max + ']';
-            }
-        case 'field':
-            const fd = criterion as FieldCriterion;
-            return getPrettyOperator(fd.operator) + ' ' + fd.value.split('|').join(', ');
-        case 'list':
-            const ls = criterion as ListCriterion;
-            return '= [' + ls.values.join(',') + ']';
-        case 'json' :
-            const json = criterion as JsonCriterion;
-            return json.path + ' ' + json.operator + ' ' + json.value;
-        case 'multiple':
-            const multiple = criterion as SelectMultipleCriterion;
-            return '[' + multiple.options.map(option => option.label).join(',') + ']';
-        default:
-            return 'Criterion type not valid!';
-    }
-}
-
 /**
  * Returns criterion notation for Anis Server.
  *
- * @param  {Criterion} criterion - The criterion to pretty print.
+ * @param  {Criterion} criterion - The criterion to transform.
  *
  * @return string
  *
  * @example
- * getCriterionStr(criterion)
+ * criterionToString(criterion)
  */
-export const getCriterionStr = (criterion: Criterion): string => {
+export const criterionToString = (criterion: Criterion): string => {
     let str: string = criterion.id.toString();
     if (criterion.type === 'between') {
         const bw = criterion as BetweenCriterion;
@@ -103,6 +66,102 @@ export const getCriterionStr = (criterion: Criterion): string => {
     return str;
 }
 
+export const stringToCriterion = (attribute: Attribute, params: string[] = null): Criterion => {
+    switch (attribute.search_type) {
+        case 'field':
+        case 'select':
+        case 'datalist':
+        case 'radio':
+        case 'date':
+        case 'date-time':
+        case 'time':
+            if (params) {
+                return { id: attribute.id, type: 'field', operator: params[1], value: params[2] } as FieldCriterion;
+            } else {
+                return { id: attribute.id, type: 'field', operator: attribute.operator, value: attribute.min.toString() } as FieldCriterion;
+            }
+        case 'list':
+            if (params) {
+                return { id: attribute.id, type: 'list', values: params[2].split('|') } as ListCriterion;
+            } else {
+                return { id: attribute.id, type: 'list', values: attribute.min.toString().split('|') } as ListCriterion;
+            }
+        case 'between':
+        case 'between-date':
+            if (params) {
+                if (params[1] === 'bw') {
+                    const bwValues = params[2].split('|');
+                    return { id: attribute.id, type: 'between', min: bwValues[0], max: bwValues[1] } as BetweenCriterion;
+                } else if (params[1] === 'gte') {
+                    return { id: attribute.id, type: 'between', min: params[2], max: null } as BetweenCriterion;
+                } else {
+                    return { id: attribute.id, type: 'between', min: null, max: params[2] } as BetweenCriterion;
+                }
+            } else {
+                return { id: attribute.id, type: 'between', min: attribute.min.toString(), max: attribute.max.toString() } as BetweenCriterion;
+            }
+        case 'select-multiple':
+        case 'checkbox':
+            if (params) {
+                const msValues = params[2].split('|');
+                const options = attribute.options.filter(option => msValues.includes(option.value));
+                return { id: attribute.id, type: 'multiple', options } as SelectMultipleCriterion;
+            } else {
+                const msValues = attribute.min.toString().split('|');
+                const options = attribute.options.filter(option => msValues.includes(option.value));
+                return { id: attribute.id, type: 'multiple', options } as SelectMultipleCriterion;
+            }
+        case 'json':
+            if (params) {
+                const [path, operator, value] = params[2].split('|');
+                return { id: attribute.id, type: 'json', path, operator, value } as JsonCriterion;
+            } else {
+                const [path, operator, value] = attribute.min.toString().split('|');
+                return { id: attribute.id, type: 'json', path, operator, value } as JsonCriterion;
+            }
+        default:
+            return null;
+    }
+}
+
+/**
+ * Returns pretty criterion string.
+ *
+ * @param  {Criterion} criterion - The criterion to pretty print.
+ *
+ * @return string
+ *
+ * @example
+ * {{ printCriterion(criterion) }}
+ */
+export const getPrettyCriterion = (criterion: Criterion): string => {
+    switch (criterion.type) {
+        case 'between':
+            const bw = criterion as BetweenCriterion;
+            if (bw.min === null) {
+                return '<= ' + bw.max;
+            } else if (bw.max === null) {
+                return '>= ' + bw.min;
+            } else {
+                return '∈ [' + bw.min + ';' + bw.max + ']';
+            }
+        case 'field':
+            const fd = criterion as FieldCriterion;
+            return getPrettyOperator(fd.operator) + ' ' + fd.value.split('|').join(', ');
+        case 'list':
+            const ls = criterion as ListCriterion;
+            return '= [' + ls.values.join(',') + ']';
+        case 'json' :
+            const json = criterion as JsonCriterion;
+            return json.path + ' ' + json.operator + ' ' + json.value;
+        case 'multiple':
+            const multiple = criterion as SelectMultipleCriterion;
+            return '[' + multiple.options.map(option => option.label).join(',') + ']';
+        default:
+            return 'Criterion type not valid!';
+    }
+}
+
 /**
  * Returns an Anis Server string operator to a pretty form label operator.
  *
@@ -114,7 +173,7 @@ export const getCriterionStr = (criterion: Criterion): string => {
  * // returns =
  * getPrettyOperator('eq')
  */
- const getPrettyOperator = (operator: string): string => {
+const getPrettyOperator = (operator: string): string => {
     switch (operator) {
         case 'eq':
             return '=';
diff --git a/client/src/app/instance/store/reducers/search.reducer.ts b/client/src/app/instance/store/reducers/search.reducer.ts
index 94650279..66221436 100644
--- a/client/src/app/instance/store/reducers/search.reducer.ts
+++ b/client/src/app/instance/store/reducers/search.reducer.ts
@@ -100,13 +100,29 @@ export const searchReducer = createReducer(
         ...state,
         selectedData: [...state.selectedData.filter(d => d !== id)]
     })),
+    on(searchActions.retrieveDataLength, state => ({
+        ...state,
+        dataLengthIsLoading: true,
+        dataLengthIsLoaded: false
+    })),
+    on(searchActions.retrieveDataLengthSuccess, (state, { length }) => ({
+        ...state,
+        dataLength: length,
+        dataLengthIsLoading: false,
+        dataLengthIsLoaded: true
+    })),
+    on(searchActions.retrieveDataLengthFail, state => ({
+        ...state,
+        dataLengthIsLoading: false
+    })),
     on(searchActions.destroyResults, state => ({
         ...state,
         searchData: [],
         dataLength: null
     })),
     on(searchActions.resetSearch, () => ({
-        ...initialState
+        ...initialState,
+        currentStep: 'dataset'
     }))
 );
 
diff --git a/client/src/app/instance/store/selectors/search.selector.ts b/client/src/app/instance/store/selectors/search.selector.ts
index af427be8..57c9c1ba 100644
--- a/client/src/app/instance/store/selectors/search.selector.ts
+++ b/client/src/app/instance/store/selectors/search.selector.ts
@@ -9,7 +9,7 @@
 
 import { createSelector } from '@ngrx/store';
 
-import { Criterion, SearchQueryParams, getCriterionStr } from '../models';
+import { Criterion, SearchQueryParams, criterionToString } from '../models';
 import * as reducer from '../../instance.reducer';
 import * as fromSearch from '../reducers/search.reducer';
 
@@ -111,7 +111,7 @@ export const selectQueryParams = createSelector(
         if (criteriaList.length > 0) {
             queryParams = {
                 ...queryParams,
-                c: criteriaList.map(criterion => getCriterionStr(criterion)).join(';')
+                c: criteriaList.map(criterion => criterionToString(criterion)).join(';')
             };
         }
         return queryParams;
diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts
index 1975b5c2..9ba0031b 100644
--- a/client/src/app/shared/shared.module.ts
+++ b/client/src/app/shared/shared.module.ts
@@ -20,6 +20,7 @@ import { AccordionModule } from 'ngx-bootstrap/accordion';
 import { PopoverModule } from 'ngx-bootstrap/popover';
 import { TooltipModule } from 'ngx-bootstrap/tooltip';
 import { BsDatepickerModule } from 'ngx-bootstrap/datepicker';
+import { TabsModule } from 'ngx-bootstrap/tabs';
 import { NgSelectModule } from '@ng-select/ng-select';
 
 import { sharedComponents } from './components';
@@ -43,6 +44,7 @@ import { sharedPipes } from './pipes';
         PopoverModule.forRoot(),
         TooltipModule.forRoot(),
         BsDatepickerModule.forRoot(),
+        TabsModule.forRoot(),
         NgSelectModule
     ],
     exports: [
@@ -57,6 +59,7 @@ import { sharedPipes } from './pipes';
         PopoverModule,
         TooltipModule,
         BsDatepickerModule,
+        TabsModule,
         NgSelectModule,
         sharedComponents,
         sharedPipes
-- 
GitLab