From a9910181e9f090eedc3a20dadca09fe663f445d1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fran=C3=A7ois=20Agneray?= <francois.agneray@lam.fr>
Date: Sat, 10 Jul 2021 13:17:37 +0200
Subject: [PATCH] Add instance shared module

---
 .../components/cone-search/dec.component.html |  92 ++++++++
 .../components/cone-search/dec.component.ts   | 221 ++++++++++++++++++
 .../shared/components/cone-search/index.ts    |  13 ++
 .../cone-search/input-group.component.scss    |  12 +
 .../components/cone-search/ra.component.html  |  92 ++++++++
 .../components/cone-search/ra.component.ts    | 218 +++++++++++++++++
 .../cone-search/radius.component.html         |  27 +++
 .../cone-search/radius.component.scss         |  14 ++
 .../cone-search/radius.component.ts           |  78 +++++++
 .../cone-search/resolver.component.html       |  15 ++
 .../cone-search/resolver.component.ts         |  45 ++++
 .../datatable/datatable.component.html        | 128 ++++++++++
 .../datatable/datatable.component.scss        |  41 ++++
 .../datatable/datatable.component.ts          | 162 +++++++++++++
 .../shared/components/datatable/index.ts      |   5 +
 .../app/instance/shared/components/index.ts   |   7 +
 client/src/app/instance/shared/pipes/index.ts |   5 +
 .../shared/pipes/pretty-operator.pipe.ts      |  27 +++
 .../src/app/instance/shared/shared.module.ts  |  25 ++
 .../app/instance/shared/validators/index.ts   |   2 +
 .../validators/nan-validator.directive.ts     |  30 +++
 .../validators/range-validator.directive.ts   |  32 +++
 .../instance/store/models/criterion.model.ts  |   2 +-
 23 files changed, 1292 insertions(+), 1 deletion(-)
 create mode 100644 client/src/app/instance/shared/components/cone-search/dec.component.html
 create mode 100644 client/src/app/instance/shared/components/cone-search/dec.component.ts
 create mode 100644 client/src/app/instance/shared/components/cone-search/index.ts
 create mode 100644 client/src/app/instance/shared/components/cone-search/input-group.component.scss
 create mode 100644 client/src/app/instance/shared/components/cone-search/ra.component.html
 create mode 100644 client/src/app/instance/shared/components/cone-search/ra.component.ts
 create mode 100644 client/src/app/instance/shared/components/cone-search/radius.component.html
 create mode 100644 client/src/app/instance/shared/components/cone-search/radius.component.scss
 create mode 100644 client/src/app/instance/shared/components/cone-search/radius.component.ts
 create mode 100644 client/src/app/instance/shared/components/cone-search/resolver.component.html
 create mode 100644 client/src/app/instance/shared/components/cone-search/resolver.component.ts
 create mode 100644 client/src/app/instance/shared/components/datatable/datatable.component.html
 create mode 100644 client/src/app/instance/shared/components/datatable/datatable.component.scss
 create mode 100644 client/src/app/instance/shared/components/datatable/datatable.component.ts
 create mode 100644 client/src/app/instance/shared/components/datatable/index.ts
 create mode 100644 client/src/app/instance/shared/components/index.ts
 create mode 100644 client/src/app/instance/shared/pipes/index.ts
 create mode 100644 client/src/app/instance/shared/pipes/pretty-operator.pipe.ts
 create mode 100644 client/src/app/instance/shared/shared.module.ts
 create mode 100644 client/src/app/instance/shared/validators/index.ts
 create mode 100644 client/src/app/instance/shared/validators/nan-validator.directive.ts
 create mode 100644 client/src/app/instance/shared/validators/range-validator.directive.ts

diff --git a/client/src/app/instance/shared/components/cone-search/dec.component.html b/client/src/app/instance/shared/components/cone-search/dec.component.html
new file mode 100644
index 00000000..93539d69
--- /dev/null
+++ b/client/src/app/instance/shared/components/cone-search/dec.component.html
@@ -0,0 +1,92 @@
+<div class="row px-3">
+    <label>DEC</label>
+    <div class="input-group">
+        <input type="text" class="form-control" [formControl]="decDegree" (input)="decChange()" autocomplete="off">
+        <div class="input-group-append">
+            <span class="input-group-text">°</span>
+        </div>
+    </div>
+</div>
+
+<div class="row mt-2 px-3">
+    <div class="col px-0 pr-xl-1">
+        <div class="input-group">
+            <input type="text"
+                   class="form-control"
+                   [formControl]="decH"
+                   (input)="decChange()"
+                   (focusin)="changeFocus('dech', true)"
+                   (focusout)="changeFocus('dech', false)"
+                   (change)="setToDefaultValue()"
+                   autocomplete="off">
+            <div class="input-group-append">
+                <span class="input-group-text">°</span>
+            </div>
+        </div>
+    </div>
+    <div class="w-100 d-block d-xl-none"></div>
+    <div class="col mt-1 mt-xl-auto px-0 pr-xl-1">
+        <div class="input-group">
+            <input type="text"
+                   class="form-control"
+                   [formControl]="decM"
+                   (input)="decChange()"
+                   (focusin)="changeFocus('decm', true)"
+                   (focusout)="changeFocus('decm', false)"
+                   (change)="setToDefaultValue()"
+                   autocomplete="off">
+            <div class="input-group-append">
+                <span class="input-group-text">'</span>
+            </div>
+        </div>
+    </div>
+    <div class="w-100 d-block d-xl-none"></div>
+    <div class="col mt-1 mt-xl-auto px-0">
+        <div class="input-group">
+            <input type="text"
+                   class="form-control"
+                   [formControl]="decS"
+                   (input)="decChange()"
+                   (focusin)="changeFocus('decs', true)"
+                   (focusout)="changeFocus('decs', false)"
+                   (change)="setToDefaultValue()"
+                   autocomplete="off">
+            <div class="input-group-append">
+                <span class="input-group-text">''</span>
+            </div>
+        </div>
+    </div>
+</div>
+
+<div *ngIf="decDegree.invalid" class="row px-3 text-danger">
+    <div *ngIf="decDegree.errors.nan">
+        {{ decDegree.errors.nan.value }}
+    </div>
+    <div *ngIf="decDegree.errors.range" [hidden]="decDegree.errors.nan">
+        {{ decDegree.errors.range.value }}
+    </div>
+</div>
+<div *ngIf="decH.invalid" class="row px-3 text-danger">
+    <div *ngIf="decH.errors.nan">
+        {{ decH.errors.nan.value }}
+    </div>
+    <div *ngIf="decH.errors.range" [hidden]="decH.errors.nan">
+        {{ decH.errors.range.value }}
+    </div>
+</div>
+<div *ngIf="decM.invalid" class="row px-3 text-danger">
+    <div *ngIf="decM.errors.nan">
+        {{ decM.errors.nan.value }}
+    </div>
+    <div *ngIf="decM.errors.range" [hidden]="decM.errors.nan">
+        {{ decM.errors.range.value }}
+    </div>
+</div>
+<div *ngIf="decS.invalid" class="row px-3 text-danger">
+    <div *ngIf="decS.errors.nan">
+        {{ decS.errors.nan.value }}
+    </div>
+    <div *ngIf="decS.errors.range" [hidden]="decS.errors.nan">
+        {{ decS.errors.range.value }}
+    </div>
+</div>
diff --git a/client/src/app/instance/shared/components/cone-search/dec.component.ts b/client/src/app/instance/shared/components/cone-search/dec.component.ts
new file mode 100644
index 00000000..35660315
--- /dev/null
+++ b/client/src/app/instance/shared/components/cone-search/dec.component.ts
@@ -0,0 +1,221 @@
+/**
+ * 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 { FormControl, Validators } from '@angular/forms';
+
+import { nanValidator, rangeValidator } from '../../validators';
+import { ConeSearch, Resolver } from 'src/app/instance/store/models';
+
+@Component({
+    selector: 'app-dec',
+    templateUrl: 'dec.component.html',
+    styleUrls: ['input-group.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush
+})
+/**
+ * @class
+ * @classdesc DEC component.
+ */
+export class DecComponent {
+    /**
+     * Disables DEC fields.
+     *
+     * @param  {boolean} disabled - If the field has to be disabled.
+     */
+    @Input()
+    set disabled(disabled: boolean) {
+        this.isDisabled = disabled;
+        this.initFields();
+    }
+    /**
+     * Sets RA, DEC and radius from cone search.
+     *
+     * @param  {ConeSearch} coneSearch - The cone search.
+     */
+    @Input()
+    set coneSearch(coneSearch: ConeSearch) {
+        this.ra = coneSearch.ra;
+        this.radius = coneSearch.radius;
+        if (coneSearch.dec) {
+            this.decDegree.setValue(coneSearch.dec);
+            if(this.decDegree.valid && !this.decHFocused && !this.decMFocused && !this.decSFocused) {
+                this.decDegree2HMS(coneSearch.dec);
+            }
+        } else {
+            this.decDegree.reset();
+            this.decH.reset();
+            this.decM.reset();
+            this.decS.reset();
+        }
+        this.initFields();
+    }
+    /**
+     * Sets RA from resolver.
+     *
+     * @param  {Resolver} resolver - The resolver.
+     */
+    @Input()
+    set resolver(resolver: Resolver) {
+        this.resolvedDec = null;
+        if (resolver) {
+            this.resolvedDec = resolver.dec;
+            this.decDegree.setValue(resolver.dec);
+            this.decDegree2HMS(resolver.dec);
+        }
+    }
+    /**
+     * Sets isDegree.
+     *
+     * @param  {string} unit - The unit.
+     */
+    @Input()
+    set unit(unit: string) {
+        unit === 'degree' ? this.isDegree = true : this.isDegree = false;
+        this.initFields();
+    }
+    @Output() updateConeSearch: EventEmitter<ConeSearch> = new EventEmitter();
+    @Output() deleteResolver: EventEmitter<null> = new EventEmitter();
+
+    ra: number;
+    radius: number;
+    isDisabled = false;
+    isDegree = true;
+    resolvedDec: number;
+    decHFocused: boolean = false;
+    decMFocused: boolean = false;
+    decSFocused: boolean = false;
+
+    decDegree = new FormControl('', [Validators.required, nanValidator, rangeValidator(-90, 90, 'DEC')]);
+    decH = new FormControl('', [nanValidator, rangeValidator(-90, 90, 'Degree')]);
+    decM = new FormControl('', [nanValidator, rangeValidator(0, 60, 'Minutes')]);
+    decS = new FormControl('', [nanValidator, rangeValidator(0, 60, 'Seconds')]);
+
+    /**
+     * Sets DEC fields.
+     */
+    initFields(): void {
+        if (this.isDisabled) {
+            this.decDegree.disable();
+            this.decH.disable();
+            this.decM.disable();
+            this.decS.disable();
+        } else if (this.isDegree) {
+            this.decDegree.enable();
+            this.decH.disable();
+            this.decM.disable();
+            this.decS.disable();
+        } else {
+            this.decDegree.disable();
+            this.decH.enable();
+            this.decM.enable();
+            this.decS.enable();
+        }
+    }
+
+    /**
+     * Converts DEC hour minute second from degree and sets DEC HMS fields.
+     *
+     * @param  {number} value - The degree value.
+     */
+    decDegree2HMS(value: number): void {
+        const hh = Math.trunc(value);
+        let tmp = (Math.abs(value - hh)) * 60;
+        const mm = Math.trunc(tmp);
+        tmp = (tmp - mm) * 60;
+        const ss = tmp.toFixed(2);
+        this.decH.setValue(hh);
+        this.decM.setValue(mm);
+        this.decS.setValue(ss);
+    }
+
+    /**
+     * Sets DEC degree from hour minute second and sets DEC degree field.
+     */
+    decHMS2Degree(): void {
+        const hh = +this.decH.value;
+        const mm = +this.decM.value;
+        const ss = +this.decS.value;
+        const tmp = ((ss / 60) + mm) / 60;
+        let deg = tmp + Math.abs(hh);
+        if (hh < 0) {
+            deg = -deg;
+        }
+        this.decDegree.setValue(+deg.toFixed(8));
+    }
+
+    /**
+     * Changes fields focus.
+     *
+     * @param  {string} field - The field.
+     * @param  {boolean} isFocused - Is the field is focused.
+     */
+    changeFocus(field: string, isFocused: boolean) {
+        switch (field) {
+            case 'dech':
+                this.decHFocused = isFocused;
+                break;
+            case 'decm':
+                this.decMFocused = isFocused;
+                break
+            case 'decs':
+                this.decSFocused = isFocused;
+                break;
+        }
+    }
+
+    /**
+     * Manages DEC value change.
+     */
+    decChange(): void {
+        if (this.isDegree) {
+            if (this.decDegree.valid) {
+                this.decDegree2HMS(this.decDegree.value);
+            } else {
+                this.decH.reset();
+                this.decM.reset();
+                this.decS.reset();
+            }
+            this.updateConeSearch.emit({ ra: this.ra, dec: this.decDegree.value, radius: this.radius } as ConeSearch);
+        } else {
+            if (this.decH.valid && this.decM.valid && this.decS.valid) {
+                this.setToDefaultValue();
+                this.decHMS2Degree();
+                this.updateConeSearch.emit({ ra: this.ra, dec: this.decDegree.value, radius: this.radius } as ConeSearch);
+            } else {
+                this.decDegree.reset();
+            }
+        }
+        this.resetResolver();
+    }
+
+    /**
+     * Sets DEC hour minute second fields to default value if not valid.
+     */
+    setToDefaultValue(): void {
+        if (this.decH.value === '' || this.decH.value === null) {
+            this.decH.setValue(0);
+        }
+        if (this.decM.value === '' || this.decM.value === null) {
+            this.decM.setValue(0);
+        }
+        if (this.decS.value === '' || this.decS.value === null) {
+            this.decS.setValue(0);
+        }
+    }
+
+    /**
+     * Emits reset resolver event.
+     */
+    resetResolver(): void {
+        if (this.resolvedDec && this.resolvedDec !== this.decDegree.value) {
+            this.deleteResolver.emit();
+        }
+    }
+}
diff --git a/client/src/app/instance/shared/components/cone-search/index.ts b/client/src/app/instance/shared/components/cone-search/index.ts
new file mode 100644
index 00000000..5f42c030
--- /dev/null
+++ b/client/src/app/instance/shared/components/cone-search/index.ts
@@ -0,0 +1,13 @@
+import { ConeSearchComponent } from './cone-search.component';
+import { ResolverComponent } from './resolver.component';
+import { RaComponent } from './ra.component';
+import { DecComponent } from './dec.component';
+import { RadiusComponent } from './radius.component';
+
+export const coneSearchComponents = [
+    ConeSearchComponent,
+    ResolverComponent,
+    RaComponent,
+    DecComponent,
+    RadiusComponent
+];
diff --git a/client/src/app/instance/shared/components/cone-search/input-group.component.scss b/client/src/app/instance/shared/components/cone-search/input-group.component.scss
new file mode 100644
index 00000000..ff4e60f4
--- /dev/null
+++ b/client/src/app/instance/shared/components/cone-search/input-group.component.scss
@@ -0,0 +1,12 @@
+/**
+ * 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.
+ */
+
+.input-group-text {
+    width: 36px;
+}
diff --git a/client/src/app/instance/shared/components/cone-search/ra.component.html b/client/src/app/instance/shared/components/cone-search/ra.component.html
new file mode 100644
index 00000000..e1ff92f6
--- /dev/null
+++ b/client/src/app/instance/shared/components/cone-search/ra.component.html
@@ -0,0 +1,92 @@
+<div class="row px-3">
+    <label>RA</label>
+    <div class="input-group">
+        <input type="text" class="form-control" [formControl]="raDegree" (input)="raChange()" autocomplete="off">
+        <div class="input-group-append">
+            <span class="input-group-text">°</span>
+        </div>
+    </div>
+</div>
+
+<div class="row mt-2 px-3">
+    <div class="col px-0 pr-xl-1">
+        <div class="input-group">
+            <input type="text"
+                   class="form-control"
+                   [formControl]="raH"
+                   (input)="raChange()"
+                   (focusin)="changeFocus('rah', true)"
+                   (focusout)="changeFocus('rah', false)"
+                   (change)="setToDefaultValue()"
+                   autocomplete="off">
+            <div class="input-group-append">
+                <span class="input-group-text">H</span>
+            </div>
+        </div>
+    </div>
+    <div class="w-100 d-block d-xl-none"></div>
+    <div class="col mt-1 mt-xl-auto px-0 pr-xl-1">
+        <div class="input-group">
+            <input type="text"
+                   class="form-control"
+                   [formControl]="raM"
+                   (input)="raChange()"
+                   (focusin)="changeFocus('ram', true)"
+                   (focusout)="changeFocus('ram', false)"
+                   (change)="setToDefaultValue()"
+                   autocomplete="off">
+            <div class="input-group-append">
+                <span class="input-group-text">'</span>
+            </div>
+        </div>
+    </div>
+    <div class="w-100 d-block d-xl-none"></div>
+    <div class="col mt-1 mt-xl-auto px-0">
+        <div class="input-group">
+            <input type="text"
+                   class="form-control"
+                   [formControl]="raS"
+                   (input)="raChange()"
+                   (focusin)="changeFocus('ras', true)"
+                   (focusout)="changeFocus('ras', false)"
+                   (change)="setToDefaultValue()"
+                   autocomplete="off">
+            <div class="input-group-append">
+                <span class="input-group-text">''</span>
+            </div>
+        </div>
+    </div>
+</div>
+
+<div *ngIf="raDegree.invalid" class="row px-3 text-danger">
+    <div *ngIf="raDegree.errors.nan">
+        {{ raDegree.errors.nan.value }}
+    </div>
+    <div *ngIf="raDegree.errors.range" [hidden]="raDegree.errors.nan">
+        {{ raDegree.errors.range.value }}
+    </div>
+</div>
+<div *ngIf="raH.invalid" class="row px-3 text-danger">
+    <div *ngIf="raH.errors.nan">
+        {{ raH.errors.nan.value }}
+    </div>
+    <div *ngIf="raH.errors.range" [hidden]="raH.errors.nan">
+        {{ raH.errors.range.value }}
+    </div>
+</div>
+<div *ngIf="raM.invalid" class="row px-3 text-danger">
+    <div *ngIf="raM.errors.nan">
+        {{ raM.errors.nan.value }}
+    </div>
+    <div *ngIf="raM.errors.range" [hidden]="raM.errors.nan">
+        {{ raM.errors.range.value }}
+    </div>
+</div>
+<div *ngIf="raS.invalid" class="row px-3 text-danger">
+    <div *ngIf="raS.errors.nan">
+        {{ raS.errors.nan.value }}
+    </div>
+    <div *ngIf="raS.errors.range" [hidden]="raS.errors.nan">
+        {{ raS.errors.range.value }}
+    </div>
+</div>
diff --git a/client/src/app/instance/shared/components/cone-search/ra.component.ts b/client/src/app/instance/shared/components/cone-search/ra.component.ts
new file mode 100644
index 00000000..30050f20
--- /dev/null
+++ b/client/src/app/instance/shared/components/cone-search/ra.component.ts
@@ -0,0 +1,218 @@
+/**
+ * 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 { FormControl, Validators } from '@angular/forms';
+
+import { nanValidator, rangeValidator } from '../../validators';
+import { ConeSearch, Resolver } from 'src/app/instance/store/models';
+
+@Component({
+    selector: 'app-ra',
+    templateUrl: 'ra.component.html',
+    styleUrls: ['input-group.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush
+})
+/**
+ * @class
+ * @classdesc RA component.
+ */
+export class RaComponent {
+    /**
+     * Disables RA fields.
+     *
+     * @param  {boolean} disabled - If the field has to be disabled.
+     */
+    @Input()
+    set disabled(disabled: boolean) {
+        this.isDisabled = disabled;
+        this.initFields();
+    }
+    /**
+     * Sets RA, DEC and radius from cone search.
+     *
+     * @param  {ConeSearch} coneSearch - The cone search.
+     */
+    @Input()
+    set coneSearch(coneSearch: ConeSearch) {
+        this.dec = coneSearch.dec;
+        this.radius = coneSearch.radius;
+        if (coneSearch.ra) {
+            this.raDegree.setValue(coneSearch.ra);
+            if (this.raDegree.valid && !this.raHFocused && !this.raMFocused && !this.raSFocused) {
+                this.raDegree2HMS(coneSearch.ra);
+            }
+        } else {
+            this.raDegree.reset();
+            this.raH.reset();
+            this.raM.reset();
+            this.raS.reset();
+        }
+        this.initFields();
+    }
+    /**
+     * Sets RA from resolver.
+     *
+     * @param  {Resolver} resolver - The resolver.
+     */
+    @Input()
+    set resolver(resolver: Resolver) {
+        this.resolvedRa = null;
+        if (resolver) {
+            this.resolvedRa = resolver.ra;
+            this.raDegree.setValue(resolver.ra);
+            this.raDegree2HMS(resolver.ra);
+        }
+    }
+    /**
+     * Sets isDegree.
+     *
+     * @param  {string} unit - The unit.
+     */
+    @Input()
+    set unit(unit: string) {
+        unit === 'degree' ? this.isDegree = true : this.isDegree = false;
+        this.initFields();
+    }
+    @Output() updateConeSearch: EventEmitter<ConeSearch> = new EventEmitter();
+    @Output() deleteResolver: EventEmitter<null> = new EventEmitter();
+
+    dec: number = null;
+    radius: number = null;
+    isDisabled = false;
+    isDegree = true;
+    resolvedRa: number;
+    raHFocused: boolean = false;
+    raMFocused: boolean = false;
+    raSFocused: boolean = false;
+
+    raDegree = new FormControl('', [Validators.required, nanValidator, rangeValidator(0, 360, 'RA')]);
+    raH = new FormControl('', [nanValidator, rangeValidator(0, 24, 'Hours')]);
+    raM = new FormControl('', [nanValidator, rangeValidator(0, 60, 'Minutes')]);
+    raS = new FormControl('', [nanValidator, rangeValidator(0, 60, 'Seconds')]);
+
+    /**
+     * Sets RA fields.
+     */
+    initFields(): void {
+        if (this.isDisabled) {
+            this.raDegree.disable();
+            this.raH.disable();
+            this.raM.disable();
+            this.raS.disable();
+        } else if (this.isDegree) {
+            this.raDegree.enable();
+            this.raH.disable();
+            this.raM.disable();
+            this.raS.disable();
+        } else {
+            this.raDegree.disable();
+            this.raH.enable();
+            this.raM.enable();
+            this.raS.enable();
+        }
+    }
+
+    /**
+     * Converts RA hour minute second from degree and sets RA HMS fields.
+     *
+     * @param  {number} value - The degree value.
+     */
+    raDegree2HMS(value: number): void {
+        let tmp = value / 15;
+        const hh = Math.trunc(tmp);
+        tmp = (tmp - hh) * 60;
+        const mm = Math.trunc(tmp);
+        tmp = (tmp - mm) * 60;
+        const ss = +tmp.toFixed(2);
+        this.raH.setValue(hh);
+        this.raM.setValue(mm);
+        this.raS.setValue(ss);
+    }
+
+    /**
+     * Sets RA degree from hour minute second and sets RA degree field.
+     */
+    raHMS2Degree(): void {
+        const hh = +this.raH.value;
+        const mm = +this.raM.value;
+        const ss = +this.raS.value;
+        const deg = +(((((ss / 60) + mm) / 60) + hh) * 15).toFixed(8);
+        this.raDegree.setValue(deg);
+    }
+
+    /**
+     * Changes fields focus.
+     *
+     * @param  {string} field - The field.
+     * @param  {boolean} isFocused - Is the field is focused.
+     */
+    changeFocus(field: string, isFocused: boolean): void {
+        switch (field) {
+            case 'rah':
+                this.raHFocused = isFocused;
+                break;
+            case 'ram':
+                this.raMFocused = isFocused;
+                break
+            case 'ras':
+                this.raSFocused = isFocused;
+                break;
+        }
+    }
+
+    /**
+     * Manages RA value change.
+     */
+    raChange(): void {
+        if (this.isDegree) {
+            if (this.raDegree.valid) {
+                this.raDegree2HMS(this.raDegree.value);
+            } else {
+                this.raH.reset();
+                this.raM.reset();
+                this.raS.reset();
+            }
+            this.updateConeSearch.emit({ ra: this.raDegree.value, dec: this.dec, radius: this.radius } as ConeSearch);
+        } else {
+            if (this.raH.valid && this.raM.valid && this.raS.valid) {
+                this.setToDefaultValue();
+                this.raHMS2Degree();
+                this.updateConeSearch.emit({ ra: this.raDegree.value, dec: this.dec, radius: this.radius } as ConeSearch);
+            } else {
+                this.raDegree.reset();
+            }
+        }
+        this.resetResolver();
+    }
+
+    /**
+     * Sets RA hour minute second fields to default value if not valid.
+     */
+    setToDefaultValue(): void {
+        if (this.raH.value === '' || this.raH.value === null) {
+            this.raH.setValue(0);
+        }
+        if (this.raM.value === '' || this.raM.value === null) {
+            this.raM.setValue(0);
+        }
+        if (this.raS.value === '' || this.raS.value === null) {
+            this.raS.setValue(0);
+        }
+    }
+
+    /**
+     * Emits reset resolver event.
+     */
+    resetResolver(): void {
+        if (this.resolvedRa && this.resolvedRa !== this.raDegree.value) {
+            this.deleteResolver.emit();
+        }
+    }
+}
diff --git a/client/src/app/instance/shared/components/cone-search/radius.component.html b/client/src/app/instance/shared/components/cone-search/radius.component.html
new file mode 100644
index 00000000..406cdfc3
--- /dev/null
+++ b/client/src/app/instance/shared/components/cone-search/radius.component.html
@@ -0,0 +1,27 @@
+<div class="row">
+    <div class="col form-group mb-0">
+        <label>Radius</label>
+        <input #rr
+            type="range"
+            min="0"
+            max="150"
+            [formControl]="radiusRange"
+            (input)="radiusChange(rr.value)"
+            class="form-control-range mt-2"
+            autocomplete="off">
+    </div>
+    <div class="w-100 d-block d-lg-none"></div>
+    <div class="col col-lg-auto form-group mb-0">
+        <div class="input-group mt-4">
+            <input #rf id="radius-field" type="number" class="form-control" [formControl]="radiusField" (input)="radiusChange(rf.value)" autocomplete="off">
+            <div class="input-group-append">
+                <span class="input-group-text">arcsecond</span>
+            </div>
+        </div>
+    </div>
+    <div *ngIf="radiusField.invalid" class="col-12 text-danger">
+        <div *ngIf="radiusField.errors.range">
+            {{ radiusField.errors.range.value }}
+        </div>
+    </div>
+</div>
diff --git a/client/src/app/instance/shared/components/cone-search/radius.component.scss b/client/src/app/instance/shared/components/cone-search/radius.component.scss
new file mode 100644
index 00000000..fcb10249
--- /dev/null
+++ b/client/src/app/instance/shared/components/cone-search/radius.component.scss
@@ -0,0 +1,14 @@
+/**
+ * This file is part of Anis Client.
+ *
+ * @copyright Laboratoire d'Astrophysique de Marseille / CNRS
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+@media (min-width: 992px) {
+    #radius-field {
+        width: 100px;
+    }
+}
diff --git a/client/src/app/instance/shared/components/cone-search/radius.component.ts b/client/src/app/instance/shared/components/cone-search/radius.component.ts
new file mode 100644
index 00000000..5add3abf
--- /dev/null
+++ b/client/src/app/instance/shared/components/cone-search/radius.component.ts
@@ -0,0 +1,78 @@
+/**
+ * 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 { FormControl } from '@angular/forms';
+
+import { rangeValidator } from '../../validators';
+import { ConeSearch } from 'src/app/instance/store/models';
+
+@Component({
+    selector: 'app-radius',
+    templateUrl: 'radius.component.html',
+    styleUrls: ['radius.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush
+})
+/**
+ * @class
+ * @classdesc Radius component.
+ */
+export class RadiusComponent {
+    /**
+     * Sets RA, DEC and radius from cone search.
+     *
+     * @param  {ConeSearch} coneSearch - The cone search.
+     */
+    @Input()
+    set coneSearch(coneSearch: ConeSearch) {
+        this.ra = coneSearch.ra;
+        this.dec = coneSearch.dec;
+        if (coneSearch.radius) {
+            this.radiusField.setValue(coneSearch.radius);
+            this.radiusRange.setValue(coneSearch.radius);
+        } else {
+            this.radiusRange.setValue(0);
+            this.radiusField.setValue(0);
+        }
+    }
+    /**
+     * Disables radius fields.
+     *
+     * @param  {boolean} disabled - If the field has to be disabled.
+     */
+    @Input()
+    set disabled(disabled: boolean) {
+        if (disabled) {
+            this.radiusField.disable();
+            this.radiusRange.disable();
+        } else {
+            this.radiusField.enable();
+            this.radiusRange.enable();
+        }
+    }
+    @Output() updateConeSearch: EventEmitter<ConeSearch> = new EventEmitter();
+
+    ra: number;
+    dec: number;
+    radiusRange = new FormControl('');
+    radiusField = new FormControl('', [rangeValidator(0, 150, 'Radius')]);
+
+    /**
+     * Sets radius value form inputs and emits cone search event.
+     *
+     * @param  {string} value - The value of radius.
+     *
+     * @fires EventEmitter<ConeSearch>
+     */
+    radiusChange(value: string): void {
+        this.radiusField.setValue(+value);
+        this.radiusRange.setValue(+value);
+        this.updateConeSearch.emit({ ra: this.ra, dec: this.dec, radius: +value } as ConeSearch);
+    }
+}
diff --git a/client/src/app/instance/shared/components/cone-search/resolver.component.html b/client/src/app/instance/shared/components/cone-search/resolver.component.html
new file mode 100644
index 00000000..86714689
--- /dev/null
+++ b/client/src/app/instance/shared/components/cone-search/resolver.component.html
@@ -0,0 +1,15 @@
+<div class="row">
+    <div class="col pr-0">
+        <label for="resolver">Resolve RA and DEC with Sesame Name Resolver</label>
+        <input #n id="resolver" type="text" class="form-control" [formControl]="field" autocomplete="off">
+    </div>
+    <div class="col-auto pt-5 pt-lg-4">
+        <button *ngIf="!resolverWip" id="btn-search" class="btn btn-outline-secondary mt-2" [disabled]="field.disabled" (click)="resolveName.emit(n.value)">
+            <span class="fas fa-search"></span>
+        </button>
+        <button *ngIf="resolverWip" id="btn-wip" class="btn btn-outline-secondary mt-2" disabled>
+            <span class="fas fa-circle-notch fa-spin"></span>
+            <span class="sr-only">Loading...</span>
+        </button>
+    </div>
+</div>
diff --git a/client/src/app/instance/shared/components/cone-search/resolver.component.ts b/client/src/app/instance/shared/components/cone-search/resolver.component.ts
new file mode 100644
index 00000000..1c017555
--- /dev/null
+++ b/client/src/app/instance/shared/components/cone-search/resolver.component.ts
@@ -0,0 +1,45 @@
+/**
+ * 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 { FormControl } from '@angular/forms';
+
+import { Resolver } from 'src/app/instance/store/models';
+
+@Component({
+    selector: 'app-resolver',
+    templateUrl: 'resolver.component.html',
+    changeDetection: ChangeDetectionStrategy.OnPush
+})
+/**
+ * @class
+ * @classdesc Resolver component.
+ */
+export class ResolverComponent {
+    @Input()
+    set disabled(disabled: boolean) {
+        if (disabled) {
+            this.field.disable();
+        } else {
+            this.field.enable();
+        }
+    }
+    @Input() resolverWip: boolean;
+    @Input()
+    set resolver(resolver: Resolver) {
+        if (resolver) {
+            this.field.setValue(resolver.name);
+        } else {
+            this.field.reset();
+        }
+    }
+    @Output() resolveName: EventEmitter<string> = new EventEmitter();
+
+    field = new FormControl('');
+}
diff --git a/client/src/app/instance/shared/components/datatable/datatable.component.html b/client/src/app/instance/shared/components/datatable/datatable.component.html
new file mode 100644
index 00000000..a16b9561
--- /dev/null
+++ b/client/src/app/instance/shared/components/datatable/datatable.component.html
@@ -0,0 +1,128 @@
+<div *ngIf="!requiredParams()" class="text-center">
+    <span class="fas fa-circle-notch fa-spin fa-3x"></span>
+    <span class="sr-only">Loading...</span>
+</div>
+<div *ngIf="requiredParams()">
+    <div *ngIf="dataset.config.datatable.selectable_row" class="mb-2">
+        <button [disabled]="noSelectedData() || processWip" (click)="executeProcess.emit('csv')"
+            class="btn btn-sm btn-outline-primary">
+            To CSV
+        </button>
+        <span *ngIf="processWip" class="float-right mr-2">
+            <span class="fas fa-circle-notch fa-spin fa-2x"></span>
+        </span>
+        <a *ngIf="processDone" href="http://0.0.0.0:8085/{{ processId }}.csv"
+            class="btn btn-sm btn-outline-secondary float-right">
+            Download your CSV
+        </a>
+    </div>
+    <div class="table-responsive">
+        <table class="table table-bordered table-hover">
+            <thead>
+                <tr>
+                    <th *ngIf="dataset.config.datatable.selectable_row">#</th>
+                    <th *ngFor="let attribute of getOutputList()" scope="col" class="clickable" (click)="sort(attribute.id)">
+                        {{ attribute.label }}
+                        <span *ngIf="attribute.id === sortedCol" class="pl-2">
+                            <span [ngClass]="{'active': sortedOrder === 'a', 'inactive': sortedOrder === 'd'}">
+                                <span class="fas fa-fw fa-sort-amount-down-alt"></span>
+                            </span>
+                            <span [ngClass]="{'active': sortedOrder === 'd', 'inactive': sortedOrder === 'a'}">
+                                <span class="fas fa-fw fa-sort-amount-up"></span>
+                            </span>
+                        </span>
+                        <span *ngIf="attribute.id !== sortedCol" class="pl-2">
+                            <span class="unsorted">
+                                <span class="fas fa-fw fa-arrows-alt-v"></span>
+                            </span>
+                            <span class="on-hover">
+                                <span class="fas fa-fw fa-sort-amount-down-alt"></span>
+                            </span>
+                        </span>
+                    </th>
+                </tr>
+            </thead>
+            <tbody>
+                <tr *ngFor="let datum of data">
+                    <td *ngIf="dataset.config.datatable.selectable_row" class="data-selected"
+                        (click)="toggleSelection(datum)">
+                        <button class="btn btn-block text-left p-0 m-0">
+                            <span *ngIf="!isSelected(datum)">
+                                <span class="far fa-square fa-lg text-secondary"></span>
+                            </span>
+                            <span *ngIf="isSelected(datum)">
+                                <span class="fas fa-check-square fa-lg theme-color"></span>
+                            </span>
+                        </button>
+                    </td>
+                    <td *ngFor="let attribute of getOutputList()" class="align-middle">
+                        <div [ngSwitch]="attribute.renderer">
+                            <div *ngSwitchCase="'detail'">
+                                <app-detail
+                                    [value]="datum[attribute.label]"
+                                    [datasetName]="dataset.name"
+                                    [config]="attribute.renderer_config">
+                                </app-detail>
+                            </div>
+                            <div *ngSwitchCase="'link'">
+                                <app-link
+                                    [value]="datum[attribute.label]"
+                                    [datasetName]="dataset.name"
+                                    [config]="attribute.renderer_config">
+                                </app-link>
+                            </div>
+                            <div *ngSwitchCase="'download'">
+                                <app-download
+                                    [value]="datum[attribute.label]"
+                                    [datasetName]="dataset.name"
+                                    [config]="attribute.renderer_config">
+                                </app-download>
+                            </div>
+                            <div *ngSwitchCase="'image'">
+                                <app-image
+                                    [value]="datum[attribute.label]"
+                                    [datasetName]="dataset.name"
+                                    [config]="attribute.renderer_config">
+                                </app-image>
+                            </div>
+                            <div *ngSwitchCase="'json'">
+                                <app-json
+                                    [value]="datum[attribute.label]"
+                                    [attributeLabel]="attribute.label"
+                                    [config]="attribute.renderer_config">
+                                </app-json>
+                            </div>
+                            <div *ngSwitchDefault>
+                                {{ datum[attribute.label] }}
+                            </div>
+                        </div>
+                    </td>
+                </tr>
+            </tbody>
+        </table>
+    </div>
+    <div class="row mt-3">
+        <div class="col">
+            Showing
+            <select class="custom-select" (change)="changeNbItems($event.target.value)">
+                <option value="10" selected="true">10</option>
+                <option value="20">20</option>
+                <option value="50">50</option>
+                <option value="100">100</option>
+            </select>
+            of {{ dataLength }} items
+        </div>
+        <div class="col-auto">
+            <pagination
+                [(ngModel)]="page"
+                [totalItems]="dataLength"
+                [boundaryLinks]="true"
+                [rotate]="true"
+                [maxSize]="5"
+                [itemsPerPage]="nbItems"
+                previousText="&lsaquo;" nextText="&rsaquo;" firstText="&laquo;" lastText="&raquo;"
+                (pageChanged)="changePage($event.page)">
+        </pagination>
+        </div>
+    </div>
+</div>
diff --git a/client/src/app/instance/shared/components/datatable/datatable.component.scss b/client/src/app/instance/shared/components/datatable/datatable.component.scss
new file mode 100644
index 00000000..25eb4367
--- /dev/null
+++ b/client/src/app/instance/shared/components/datatable/datatable.component.scss
@@ -0,0 +1,41 @@
+/**
+ * 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.
+ */
+
+ .data-selected {
+    cursor: pointer;
+}
+
+.data-selected button:focus {
+    box-shadow: none;
+}
+
+ul {
+    margin-bottom: 0;
+}
+
+.custom-select {
+    width: fit-content;
+}
+
+.clickable:hover {
+    cursor: pointer;
+    background-color: #F7F7F7;
+}
+
+.unsorted {
+    color: #c5c5c5;
+}
+
+.clickable:hover .unsorted, .on-hover, .inactive, .clickable:hover .active {
+    display: none;
+}
+
+.clickable:hover .on-hover, .clickable:hover .inactive {
+    display: inline;
+}
diff --git a/client/src/app/instance/shared/components/datatable/datatable.component.ts b/client/src/app/instance/shared/components/datatable/datatable.component.ts
new file mode 100644
index 00000000..33150c61
--- /dev/null
+++ b/client/src/app/instance/shared/components/datatable/datatable.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, EventEmitter, Input, OnInit, Output } from '@angular/core';
+
+import { Attribute, Dataset } from 'src/app/metamodel/models';
+import { Pagination, PaginationOrder } from 'src/app/instance/store/models';
+
+
+@Component({
+    selector: 'app-datatable',
+    templateUrl: 'datatable.component.html',
+    styleUrls: ['datatable.component.scss'],
+})
+/**
+ * @class
+ * @classdesc Datatable component.
+ *
+ * @implements OnInit
+ */
+export class DatatableComponent implements OnInit {
+    @Input() dataset: Dataset;
+    @Input() attributeList: Attribute[];
+    @Input() outputList: number[];
+    @Input() data: any[];
+    @Input() dataLength: number;
+    @Input() selectedData: any[] = [];
+    @Input() processWip: boolean = false;
+    @Input() processDone: boolean = false;
+    @Input() processId: string = null;
+    @Output() getData: EventEmitter<Pagination> = new EventEmitter();
+    @Output() addSelectedData: EventEmitter<number | string> = new EventEmitter();
+    @Output() deleteSelectedData: EventEmitter<number | string> = new EventEmitter();
+    @Output() executeProcess: EventEmitter<string> = new EventEmitter();
+    nbItems = 10;
+    page = 1;
+    sortedCol: number = null;
+    sortedOrder: PaginationOrder = PaginationOrder.a;
+    
+    ngOnInit() {
+        this.sortedCol = this.attributeList.find(a => a.order_by).id;
+    }
+
+    /**
+     * Checks if required parameters to display datatable are passed to the component.
+     *
+     * @return boolean
+     */
+    requiredParams(): boolean {
+        if (this.attributeList.length === 0 || this.outputList.length === 0 || !this.dataLength) {
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * Checks if there is no data selected.
+     *
+     * @return boolean
+     */
+    noSelectedData(): boolean {
+        return this.selectedData.length < 1;
+    }
+
+    /**
+     * Returns output list from attribute list.
+     *
+     * @return Attribute[]
+     */
+    getOutputList(): Attribute[] {
+        return this.attributeList
+            .filter(a => this.outputList.includes(a.id))
+            .sort((a, b) => a.output_display - b.output_display);
+    }
+
+    /**
+     * Emits events to select or unselect data.
+     *
+     * @param  {any} datum - The data to select or unselect.
+     *
+     * @fires EventEmitter<number | string>
+     */
+    toggleSelection(datum: any): void {
+        const attribute = this.attributeList.find(a => a.search_flag === 'ID');
+        const index = this.selectedData.indexOf(datum[attribute.label]);
+        if (index > -1) {
+            this.deleteSelectedData.emit(datum[attribute.label]);
+        } else {
+            this.addSelectedData.emit(datum[attribute.label]);
+        }
+    }
+
+    /**
+     * Checks if data is selected.
+     *
+     * @param  {any} datum - The data.
+     *
+     * @return boolean
+     */
+    isSelected(datum: any): boolean {
+        const attribute = this.attributeList.find(a => a.search_flag === 'ID');
+
+        if (this.selectedData.indexOf(datum[attribute.label]) > -1) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Emits event to change datatable page.
+     *
+     * @param  {number} nb - The page number to access.
+     *
+     * @fires EventEmitter<Pagination>
+     */
+    changePage(nb: number): void {
+        this.page = nb;
+        const pagination: Pagination = {
+            dname: this.dataset.name,
+            page: this.page,
+            nbItems: this.nbItems,
+            sortedCol: this.sortedCol,
+            order: this.sortedOrder
+        };
+        this.getData.emit(pagination);
+    }
+
+    /**
+     * Emits event to change datatable displayed items.
+     *
+     * @param  {number} nb - The number of items to display.
+     *
+     * @fires EventEmitter<Pagination>
+     */
+    changeNbItems(nb: number): void {
+        this.nbItems = nb;
+        this.changePage(1);
+    }
+
+    /**
+     * Emits event to change the sorted order and the sorted column of the datatable.
+     *
+     * @param  {number} id - The id of the column to sort.
+     *
+     * @fires EventEmitter<Pagination>
+     */
+    sort(id: number): void {
+        if (id === this.sortedCol) {
+            this.sortedOrder = this.sortedOrder === PaginationOrder.a ? PaginationOrder.d : PaginationOrder.a;
+        } else {
+            this.sortedCol = id;
+            this.sortedOrder = PaginationOrder.a;
+        }
+        this.changePage(1);
+    }
+}
diff --git a/client/src/app/instance/shared/components/datatable/index.ts b/client/src/app/instance/shared/components/datatable/index.ts
new file mode 100644
index 00000000..c0ec2582
--- /dev/null
+++ b/client/src/app/instance/shared/components/datatable/index.ts
@@ -0,0 +1,5 @@
+import { DatatableComponent } from "./datatable.component";
+
+export const datatableComponents = [
+    DatatableComponent
+];
diff --git a/client/src/app/instance/shared/components/index.ts b/client/src/app/instance/shared/components/index.ts
new file mode 100644
index 00000000..1bd2ebbd
--- /dev/null
+++ b/client/src/app/instance/shared/components/index.ts
@@ -0,0 +1,7 @@
+import { coneSearchComponents } from './cone-search';
+import { datatableComponents } from './datatable';
+
+export const sharedComponents = [
+    coneSearchComponents,
+    datatableComponents
+];
diff --git a/client/src/app/instance/shared/pipes/index.ts b/client/src/app/instance/shared/pipes/index.ts
new file mode 100644
index 00000000..08f7ea71
--- /dev/null
+++ b/client/src/app/instance/shared/pipes/index.ts
@@ -0,0 +1,5 @@
+import { PrettyOperatorPipe } from './pretty-operator.pipe';
+
+export const sharedPipes = [
+    PrettyOperatorPipe
+];
diff --git a/client/src/app/instance/shared/pipes/pretty-operator.pipe.ts b/client/src/app/instance/shared/pipes/pretty-operator.pipe.ts
new file mode 100644
index 00000000..05cd736f
--- /dev/null
+++ b/client/src/app/instance/shared/pipes/pretty-operator.pipe.ts
@@ -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.
+ */
+
+import { Pipe, PipeTransform } from '@angular/core';
+
+import { getPrettyOperator } from 'src/app/instance/store/models';
+
+/**
+ * @class
+ * @classdesc Translate Anis string operator to a pretty form label operator.
+ *
+ * @example
+ * // formats eq to =
+ * {{ eq | prettyOperator }}
+ */
+@Pipe({ name: 'prettyOperator' })
+export class PrettyOperatorPipe implements PipeTransform {
+    transform(operator: string): string {
+        return getPrettyOperator(operator);
+    }
+}
diff --git a/client/src/app/instance/shared/shared.module.ts b/client/src/app/instance/shared/shared.module.ts
new file mode 100644
index 00000000..78bdbd61
--- /dev/null
+++ b/client/src/app/instance/shared/shared.module.ts
@@ -0,0 +1,25 @@
+/**
+ * This file is part of Anis Client.
+ *
+ * @copyright Laboratoire d'Astrophysique de Marseille / CNRS
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+import { NgModule } from '@angular/core';
+
+import { sharedComponents } from './components';
+import { sharedPipes } from './pipes';
+
+@NgModule({
+    declarations: [
+        sharedComponents,
+        sharedPipes
+    ],
+    exports: [
+        sharedComponents,
+        sharedPipes
+    ]
+})
+export class SharedModule { }
diff --git a/client/src/app/instance/shared/validators/index.ts b/client/src/app/instance/shared/validators/index.ts
new file mode 100644
index 00000000..6cb062e6
--- /dev/null
+++ b/client/src/app/instance/shared/validators/index.ts
@@ -0,0 +1,2 @@
+export * from './range-validator.directive';
+export * from './nan-validator.directive';
diff --git a/client/src/app/instance/shared/validators/nan-validator.directive.ts b/client/src/app/instance/shared/validators/nan-validator.directive.ts
new file mode 100644
index 00000000..eb5d9d72
--- /dev/null
+++ b/client/src/app/instance/shared/validators/nan-validator.directive.ts
@@ -0,0 +1,30 @@
+/**
+ * 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 { FormControl } from '@angular/forms';
+
+/**
+ * Validates NaN.
+ *
+ * @param  {FormControl} control - The form control where to check NaN.
+ *
+ * @return {[key: string]: any} | null
+ */
+export function nanValidator(control: FormControl): {[key: string]: any} | null {
+    const value = parseFloat(control.value);
+    const regexp: RegExp = /^\-?\d+([.,]+\d*)?$/;
+    const isFloat = regexp.test(control.value);
+    if (control.value === '' || control.value === null) {
+        return null;
+    }
+    if (isNaN(value) || !isFloat) {
+        return { 'nan': { value: control.value + ' is not a number' } };
+    }
+    return null;
+}
diff --git a/client/src/app/instance/shared/validators/range-validator.directive.ts b/client/src/app/instance/shared/validators/range-validator.directive.ts
new file mode 100644
index 00000000..fa4ef419
--- /dev/null
+++ b/client/src/app/instance/shared/validators/range-validator.directive.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 { ValidatorFn, AbstractControl } from '@angular/forms';
+
+/**
+ * Validates range.
+ *
+ * @param  {number} min - The minimum for the range.
+ * @param  {number} max - The maximum for the range.
+ * @param  {string} [formLabel] - The label of the form displayed in error message.
+ *
+ * @return ValidatorFn
+ */
+export function rangeValidator(min: number, max: number, formLabel?: string): ValidatorFn {
+    return (control: AbstractControl): {[key: string]: any} | null => {
+        const value = parseFloat(control.value);
+        if (value < min || value > max) {
+            if (formLabel) {
+                return { 'range': { value: formLabel + ' must be between ' + min + ' and ' + max } };
+            }
+            return { 'range': { value: 'Must be between ' + min + ' and ' + max } };
+        }
+        return null;
+    }
+}
diff --git a/client/src/app/instance/store/models/criterion.model.ts b/client/src/app/instance/store/models/criterion.model.ts
index 1b2f328a..81d6161c 100644
--- a/client/src/app/instance/store/models/criterion.model.ts
+++ b/client/src/app/instance/store/models/criterion.model.ts
@@ -173,7 +173,7 @@ export const getPrettyCriterion = (criterion: Criterion): string => {
  * // returns =
  * getPrettyOperator('eq')
  */
-const getPrettyOperator = (operator: string): string => {
+export const getPrettyOperator = (operator: string): string => {
     switch (operator) {
         case 'eq':
             return '=';
-- 
GitLab