Commit 0a27a401 authored by François Agneray's avatar François Agneray
Browse files

Refactor result page => done

parent 2626c373
......@@ -26,6 +26,7 @@
"@ngrx/store-devtools": "13.0.2",
"bootstrap": "4.6.1",
"d3": "^5.15.1",
"file-saver": "^2.0.5",
"keycloak-angular": "^9.1.0",
"keycloak-js": "^16.1.1",
"ngx-bootstrap": "^8.0.0",
......@@ -40,6 +41,7 @@
"@angular/cli": "~13.2.3",
"@angular/compiler-cli": "~13.2.2",
"@types/d3": "^5.7.2",
"@types/file-saver": "^2.0.5",
"@types/jasmine": "~3.10.0",
"@types/jest": "^27.4.0",
"@types/node": "^12.11.1",
......@@ -49,4 +51,4 @@
"jest-preset-angular": "^11.1.0",
"typescript": "~4.5.5"
}
}
\ No newline at end of file
}
......@@ -15,7 +15,7 @@ import * as searchMultiple from './store/reducers/search-multiple.reducer';
import * as coneSearch from './store/reducers/cone-search.reducer';
import * as detail from './store/reducers/detail.reducer';
import * as svomJsonKw from './store/reducers/svom-json-kw.reducer';
import * as downloadFile from './store/reducers/download-file.reducer';
import * as archive from './store/reducers/archive.reducer';
/**
* Interface for instance state.
......@@ -28,7 +28,7 @@ export interface State {
coneSearch: coneSearch.State
detail: detail.State,
svomJsonKw: svomJsonKw.State,
downloadFile: downloadFile.State
archive: archive.State
}
const reducers = {
......@@ -37,7 +37,7 @@ const reducers = {
coneSearch: coneSearch.coneSearchReducer,
detail: detail.detailReducer,
svomJsonKw: svomJsonKw.svomJsonKwReducer,
downloadFile: downloadFile.fileReducer
archive: archive.archiveReducer
};
export const instanceReducer = combineReducers(reducers);
......
import { Directive, Input, Output, EventEmitter } from '@angular/core';
import { Dataset } from 'src/app/metamodel/models';
import { Criterion, ConeSearch, criterionToString } from 'src/app/instance/store/models';
import { AppConfigService } from 'src/app/app-config.service';
import { getHost } from 'src/app/shared/utils';
@Directive()
export abstract class AbstractDownloadComponent {
@Input() dataset: Dataset;
@Input() criteriaList: Criterion[];
@Input() outputList: number[];
@Input() coneSearch: ConeSearch;
@Input() archiveIsCreating: boolean;
@Output() downloadFile: EventEmitter<{url: string, filename: string}> = new EventEmitter();
constructor(private appConfig: AppConfigService) { }
/**
* Returns API URL to get data with user parameters.
*
* @return string
*/
getUrl(format: string, selectedData: string = null): string {
return `${getHost(this.appConfig.apiUrl)}/search/${this.getQuery(selectedData)}&f=${format}`;
}
getQuery(selectedData: string = null) {
let query = `${this.dataset.name}?a=${this.outputList.join(';')}`;
if (this.criteriaList.length > 0) {
query += `&c=${this.criteriaList.map(criterion => criterionToString(criterion)).join(';')}`;
if (selectedData) {
query += `;${selectedData}`;
}
} else if (selectedData) {
query += `&c=${selectedData}`;
}
if (this.coneSearch) {
query += `&cs=${this.coneSearch.ra}:${this.coneSearch.dec}:${this.coneSearch.radius}`;
}
return query;
}
download(event, url: string, format: string) {
event.preventDefault();
const timeElapsed = Date.now();
const today = new Date(timeElapsed);
const filename = `result_${this.dataset.name}_${today.toISOString()}.${this.formatToExtension(format)}`;
this.downloadFile.emit({ url, filename });
}
formatToExtension(format: string) {
let extension: string;
switch (format) {
case 'json': {
extension = 'json';
break;
}
case 'csv': {
extension = 'csv';
break;
}
case 'ascii': {
extension = 'txt';
break;
}
case 'votable': {
extension = 'xml';
break;
}
default: {
extension = 'json';
break;
}
}
return extension;
}
}
\ No newline at end of file
<div id="plot" class="row bg-light"></div>
\ No newline at end of file
<div id="plot" class="row justify-content-center"></div>
\ No newline at end of file
......@@ -7,13 +7,12 @@
* file that was distributed with this source code.
*/
import { Component, Input, ChangeDetectionStrategy, ViewEncapsulation, OnInit, SimpleChanges } from '@angular/core';
import { Component, Input, ChangeDetectionStrategy, ViewEncapsulation, OnInit } from '@angular/core';
import * as d3 from 'd3';
import { Dataset, Image } from 'src/app/metamodel/models';
import { Dataset } from 'src/app/metamodel/models';
import { ConeSearch } from 'src/app/instance/store/models';
import { AppConfigService } from 'src/app/app-config.service';
/**
* @class
......@@ -30,7 +29,7 @@ export class ConeSearchPlotComponent implements OnInit {
@Input() coneSearch: ConeSearch;
@Input() dataset: Dataset;
@Input() data: {x: number, y: number}[];
@Input() selectedBackground: Image;
@Input() backgroundHref: string;
// Interactive variables intialisation
margin = { top: 50, right: 50, bottom: 50 , left: 50 };
......@@ -41,42 +40,10 @@ export class ConeSearchPlotComponent implements OnInit {
x: d3.ScaleLinear<number, number>;
y: d3.ScaleLinear<number, number>;
constructor(private config: AppConfigService) { }
ngOnInit(): void {
this.coneSearchPlot();
}
ngOnChanges(changes: SimpleChanges) {
if (changes.selectedBackground && changes.selectedBackground.currentValue) {
console.log('coucou');
this.image.attr('xlink:href', this.getHrefBackgroundImage());
}
}
getHrefBackgroundImage() {
if (this.selectedBackground) {
let href = `${this.config.servicesUrl}/fits-cut-to-png/${this.dataset.name}?filename=${this.selectedBackground.file_path}`;
href += `&ra=${this.coneSearch.ra}`;
href += `&dec=${this.coneSearch.dec}`;
href += `&radius=${this.coneSearch.radius}`;
href += `&stretch=${this.selectedBackground.stretch}`;
href += `&pmin=${this.selectedBackground.pmin}`;
href += `&pmax=${this.selectedBackground.pmax}`;
href += `&axes=false`;
return href;
} else {
const scale = this.coneSearch.radius / this.width; // arcsec/pix
return `https://skyserver.sdss.org/dr16/SkyServerWS/ImgCutout/getjpeg?TaskName=Skyserver.Chart.Image
&ra=${this.coneSearch.ra}
&dec=${this.coneSearch.dec}
&scale=${scale}
&width=${this.width}
&height=${this.height}`;
}
}
coneSearchPlot(): void {
// Init SVG
const svg = d3.select('#plot').append('svg')
......@@ -93,7 +60,7 @@ export class ConeSearchPlotComponent implements OnInit {
// Background image
this.image = svg.append('image');
this.image.attr('xlink:href', this.getHrefBackgroundImage())
this.image.attr('xlink:href', this.backgroundHref)
.attr('width', this.width)
.attr('height', this.height);
......
<div *ngIf="getDataset().datatable_selectable_rows" class="btn-group mb-2" dropdown [isDisabled]="selectedData.length < 1">
<div *ngIf="dataset.datatable_selectable_rows" class="btn-group mb-2" dropdown [isDisabled]="selectedData.length < 1">
<button id="button-basic" dropdownToggle type="button" class="btn btn-primary dropdown-toggle" aria-controls="dropdown-basic">
Actions <span class="caret"></span>
</button>
<ul id="dropdown-basic" *dropdownMenu class="dropdown-menu" role="menu" aria-labelledby="button-basic">
<li *ngIf="getConfigDownloadResultFormat('download_csv')" role="menuitem">
<a class="dropdown-item" (click)="downloadResult('csv')">
<li *ngIf="dataset.download_json" role="menuitem">
<a class="dropdown-item" [href]="getDatatableUrl('json')" (click)="download($event, getDatatableUrl('json'), 'json')">
<span class="fas fa-file"></span> Download JSON
</a>
</li>
<li *ngIf="dataset.download_csv" role="menuitem">
<a class="dropdown-item" [href]="getDatatableUrl('csv')" (click)="download($event, getDatatableUrl('csv'), 'csv')">
<span class="fas fa-file-csv"></span> Download CSV
</a>
</li>
<li *ngIf="getConfigDownloadResultFormat('download_ascii')" role="menuitem">
<a class="dropdown-item" (click)="downloadResult('ascii')">
<li *ngIf="dataset.download_ascii" role="menuitem">
<a class="dropdown-item" [href]="getDatatableUrl('ascii')" (click)="download($event, getDatatableUrl('ascii'), 'ascii')">
<span class="fas fa-file"></span> Download ASCII
</a>
</li>
<li *ngIf="getConfigDownloadResultFormat('download_vo')" role="menuitem">
<a class="dropdown-item" (click)="downloadResult('votable')">
<li *ngIf="dataset.download_vo" role="menuitem">
<a class="dropdown-item" [href]="getDatatableUrl('votable')" (click)="download($event, getDatatableUrl('votable'), 'votable')">
<span class="fas fa-file"></span> VOtable
</a>
</li>
<li *ngIf="getConfigDownloadResultFormat('download_vo')" role="menuitem" [class.disabled]="!sampRegistered">
<li *ngIf="dataset.download_vo" role="menuitem" [class.disabled]="!sampRegistered">
<a class="dropdown-item" [class.disabled]="!sampRegistered" (click)="broadcastResult()">
<span class="fas fa-broadcast-tower"></span> Broadcast VOtable
</a>
</li>
<li *ngIf="getConfigDownloadResultFormat('download_archive')" role="menuitem">
<a class="dropdown-item" (click)="downloadArchive()">
<li *ngIf="isArchiveIsAvailable()" role="menuitem" [class.disabled]="archiveIsCreating">
<a class="dropdown-item" [class.disabled]="archiveIsCreating" (click)="downloadArchive()">
<span class="fas fa-archive"></span> Download files archive
</a>
</li>
......
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { Dataset } from 'src/app/metamodel/models';
import { AbstractDownloadComponent } from './abstract-download.component';
import { Attribute } from 'src/app/metamodel/models';
@Component({
selector: 'app-datatable-actions',
templateUrl: 'datatable-actions.component.html'
})
export class DatatableActionsComponent {
export class DatatableActionsComponent extends AbstractDownloadComponent {
@Input() attributeList: Attribute[];
@Input() selectedData: any[] = [];
@Input() datasetSelected: string;
@Input() datasetList: Dataset[];
@Input() sampRegistered: boolean;
@Output() startTaskCreateResult: EventEmitter<{ format: string, selectedData: boolean, broadcastVo: boolean }> = new EventEmitter();
@Output() startTaskCreateArchive: EventEmitter<{ selectedData: boolean }> = new EventEmitter();
@Output() broadcastVotable: EventEmitter<string> = new EventEmitter();
@Output() startTaskCreateArchive: EventEmitter<string> = new EventEmitter();
/**
* Checks if the download format is allowed by Anis Admin configuration.
*
* @param {string} format - The file format to download.
*
* @return boolean
*/
getConfigDownloadResultFormat(format: string): boolean {
return this.getDataset()[format];
isArchiveIsAvailable() {
return this.attributeList
.filter(attribute => this.outputList.includes(attribute.id))
.filter(attribute => attribute.archive)
.length > 0;
}
getDataset() {
return this.datasetList.find(d => d.name === this.datasetSelected);
getDatatableUrl(format: string): string {
const attributeId = this.attributeList.find(a => a.primary_key);
return this.getUrl(format, `${attributeId.id}::in::${this.selectedData.join('|')}`);
}
downloadResult(format: string) {
this.startTaskCreateResult.emit({
format,
selectedData: true,
broadcastVo: false
});
}
broadcastResult() {
this.startTaskCreateResult.emit({
format: 'votable',
selectedData: true,
broadcastVo: true
})
const url = this.getDatatableUrl('votable');
this.broadcastVotable.emit(url);
}
downloadArchive() {
this.startTaskCreateArchive.emit({
selectedData: true
});
const attributeId = this.attributeList.find(a => a.primary_key);
this.startTaskCreateArchive.emit(this.getQuery(`${attributeId.id}::in::${this.selectedData.join('|')}`));
}
}
<accordion *ngIf="(datasetList | datasetByName:datasetSelected).datatable_enabled" [isAnimated]="true">
<accordion-group #ag [isOpen]="(datasetList | datasetByName:datasetSelected).datatable_opened" [panelClass]="'custom-accordion'" class="my-2">
<button class="btn btn-link btn-block clearfix" accordion-heading>
<span class="pull-left float-left">
Display result details
&nbsp;
<span *ngIf="ag.isOpen"><span class="fas fa-chevron-up"></span></span>
<span *ngIf="!ag.isOpen"><span class="fas fa-chevron-down"></span></span>
</span>
</button>
<div class="row">
<div class="col-md-5" *ngIf="coneSearch">
<div class="form-group">
<label for="file_size">Background image</label>
<ng-select [(ngModel)]="selectedBackground">
<ng-option *ngFor="let image of imageList" [value]="image">{{ image.file_path }}</ng-option>
</ng-select>
</div>
<app-cone-search-plot *ngIf="dataIsLoaded"
[coneSearch]="coneSearch"
[dataset]="datasetList | datasetByName:datasetSelected"
[data]="getData()"
[selectedBackground]="selectedBackground">
</app-cone-search-plot>
</div>
<div class="datatable-group" [ngClass]="{'col': !coneSearch, 'col-md-7' : coneSearch }">
<div class="row">
<div class="col">
<app-datatable-actions
[selectedData]="selectedData"
[datasetSelected]="datasetSelected"
[datasetList]="datasetList"
[sampRegistered]="sampRegistered"
(broadcast)="broadcast.emit($event)"
(startTaskCreateResult)="startTaskCreateResult.emit($event)"
(startTaskCreateArchive)="startTaskCreateArchive.emit($event)">
</app-datatable-actions>
<app-datatable
[dataset]="datasetList | datasetByName:datasetSelected"
[instance]="instance"
[attributeList]="attributeList"
[outputList]="outputList"
[queryParams]="queryParams"
[dataLength]="dataLength"
[data]="data"
[dataIsLoading]="dataIsLoading"
[dataIsLoaded]="dataIsLoaded"
[selectedData]="selectedData"
(retrieveData)="retrieveData.emit($event)"
(addSelectedData)="addSelectedData.emit($event)"
(deleteSelectedData)="deleteSelectedData.emit($event)"
(downloadFile)="downloadFile.emit($event)">
</app-datatable>
</div>
</div>
</div>
</div>
</accordion-group>
</accordion>
.datatable-group > .row {
overflow-x: auto;
white-space: nowrap;
}
.datatable-group > .row > .col {
display: inline-block;
float: none;
}
\ No newline at end of file
import { Component, Input } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { AccordionModule } from 'ngx-bootstrap/accordion';
import { DatatableTabComponent } from './datatable-tab.component';
import { Attribute, Dataset, Instance } from '../../../../metamodel/models';
import { SearchQueryParams, Criterion, ConeSearch } from '../../../store/models';
import { DatasetByNamePipe } from '../../../../shared/pipes/dataset-by-name.pipe';
describe('[Instance][Search][Component][Result] DatatableTabComponent', () => {
@Component({ selector: 'app-datatable', template: '' })
class DatatableStubComponent {
@Input() dataset: Dataset;
@Input() instance: Instance;
@Input() attributeList: Attribute[];
@Input() outputList: number[];
@Input() queryParams: SearchQueryParams;
@Input() dataLength: number;
@Input() data: any[];
@Input() dataIsLoading: boolean;
@Input() dataIsLoaded: boolean;
@Input() selectedData: any[] = [];
}
@Component({ selector: 'app-datatable-actions', template: '' })
class DatatableActionsStubComponent {
@Input() selectedData: any[] = [];
@Input() datasetSelected: string;
@Input() datasetList: Dataset[];
@Input() attributeList: Attribute[];
@Input() criteriaList: Criterion[];
@Input() outputList: number[];
@Input() coneSearch: ConeSearch;
@Input() dataLength: number;
@Input() sampRegistered: boolean;
}
let component: DatatableTabComponent;
let fixture: ComponentFixture<DatatableTabComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [
DatatableTabComponent,
DatatableStubComponent,
DatatableActionsStubComponent,
DatasetByNamePipe
],
imports: [
AccordionModule.forRoot(),
BrowserAnimationsModule
]
});
fixture = TestBed.createComponent(DatatableTabComponent);
component = fixture.componentInstance;
});
it('should create the component', () => {
expect(component).toBeTruthy();
});
});
/**
* 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 { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { Instance, Attribute, Dataset, Image } from 'src/app/metamodel/models';
import { Pagination, SearchQueryParams, Criterion, ConeSearch } from 'src/app/instance/store/models';
/**
* @class
* @classdesc Search result datatable tab component.
*/
@Component({
selector: 'app-datatable-tab',
templateUrl: 'datatable-tab.component.html',
styleUrls: [ 'datatable-tab.component.scss' ],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DatatableTabComponent {
@Input() datasetSelected: string;
@Input() instance: Instance;
@Input() datasetList: Dataset[];
@Input() attributeList: Attribute[];
@Input() outputList: number[];
@Input() criteriaList: Criterion[];
@Input() coneSearch: ConeSearch;
@Input() queryParams: SearchQueryParams;
@Input() dataLength: number;
@Input() sampRegistered: boolean;
@Input() data: any[];
@Input() dataIsLoading: boolean;
@Input() dataIsLoaded: boolean;
@Input() selectedData: any[];
@Input() imageList: Image[];
@Input() imageListIsLoading: boolean;
@Input() imageListIsLoaded: boolean;
@Output() retrieveData: EventEmitter<Pagination> = new EventEmitter();
@Output() addSelectedData: EventEmitter<number | string> = new EventEmitter();
@Output() deleteSelectedData: EventEmitter<number | string> = new EventEmitter();
@Output() broadcast: EventEmitter<string> = new EventEmitter();
@Output() startTaskCreateResult: EventEmitter<{ format: string, selectedData: boolean, broadcastVo: boolean }> = new EventEmitter();
@Output() startTaskCreateArchive: EventEmitter<{ selectedData: boolean }> = new EventEmitter();
@Output() downloadFile: EventEmitter<{url: string, fileId: string, datasetName: string, filename: string}> = new EventEmitter();
selectedBackground: Image = null;
getData() {
const dataset = this.getDataset();
const columnRa = this.attributeList.find(a => a.id === dataset.cone_search_column_ra);
const columnDec = this.attributeList.find(a => a.id === dataset.cone_search_column_dec);
return this.data.map(d => ({ "x": +d[columnRa.label], "y": +d[columnDec.label] }));
}
/**
* Returns selected dataset for the search.
*
* @return Dataset
*/
getDataset(): Dataset {
return this.datasetList.find(dataset => dataset.name === this.datasetSelected);
}
}
......@@ -7,11 +7,6 @@
* file that was distributed with this source code.
*/
.table-responsive {
overflow-y: scroll;
height: 650px;
}
table th:not(.select) {
min-width: 130px;
}
......
......@@ -44,7 +44,7 @@ export class DatatableComponent implements OnInit {
@Output() retrieveData: EventEmitter<Pagination> = new EventEmitter();
@Output() addSelectedData: EventEmitter<number | string> = new EventEmitter();
@Output() deleteSelectedData: EventEmitter<number | string> = new EventEmitter();
@Output() downloadFile: EventEmitter<{url: string, fileId: string, datasetName: string, filename: string}> = new EventEmitter();
@Output() downloadFile: EventEmitter<{url: string, filename: string}> = new EventEmitter();
public page = 1;
public nbItems = 10;
......