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