diff --git a/client/src/app/instance/search/components/result/datatable-actions.component.html b/client/src/app/instance/search/components/result/datatable-actions.component.html new file mode 100644 index 0000000000000000000000000000000000000000..a1ff010a95fbcfa5304aadb79791f8841c341466 --- /dev/null +++ b/client/src/app/instance/search/components/result/datatable-actions.component.html @@ -0,0 +1,32 @@ +<div *ngIf="getDataset().config.datatable.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" [href]="getUrl('csv')" (click)="click($event, getUrl('csv'), 'csv')"> + <span class="fas fa-file-csv"></span> Download CSV + </a> + </li> + <li *ngIf="getConfigDownloadResultFormat('download_ascii')" role="menuitem"> + <a class="dropdown-item" [href]="getUrl('ascii')" (click)="click($event, getUrl('ascii'), 'txt')"> + <span class="fas fa-file"></span> Download ASCII + </a> + </li> + <li *ngIf="getConfigDownloadResultFormat('download_vo')" role="menuitem"> + <a class="dropdown-item" [href]="getUrl('votable')" (click)="click($event, getUrl('votable'), 'xml')"> + <span class="fas fa-file"></span> VOtable + </a> + </li> + <li *ngIf="getConfigDownloadResultFormat('download_vo')" role="menuitem"> + <a class="dropdown-item" (click)="broadcastVotable()"> + <span class="fas fa-broadcast-tower"></span> Broadcast VOtable + </a> + </li> + <li *ngIf="getConfigDownloadResultFormat('download_archive')" role="menuitem"> + <a class="dropdown-item" [href]="getUrlArchive()" (click)="click($event, getUrlArchive(), 'zip')"> + <span class="fas fa-archive"></span> Download files archive + </a> + </li> + </ul> +</div> diff --git a/client/src/app/instance/search/components/result/datatable-actions.component.ts b/client/src/app/instance/search/components/result/datatable-actions.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..70e64e95affb137011a054a176925dca4ac946ec --- /dev/null +++ b/client/src/app/instance/search/components/result/datatable-actions.component.ts @@ -0,0 +1,111 @@ +import { Component, Input, Output, EventEmitter } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; + +import { Dataset, Attribute } from 'src/app/metamodel/models'; +import { Criterion, ConeSearch, criterionToString } from 'src/app/instance/store/models'; +import { getHost } from 'src/app/shared/utils'; +import { AppConfigService } from 'src/app/app-config.service'; + +@Component({ + selector: 'app-datatable-actions', + templateUrl: 'datatable-actions.component.html' +}) +export class DatatableActionsComponent { + @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; + @Output() broadcast: EventEmitter<string> = new EventEmitter(); + + constructor(private appConfig: AppConfigService, private http: HttpClient) { } + + /** + * 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 { + const dataset = this.getDataset(); + return dataset.config.download[format]; + } + + getDataset() { + return this.datasetList.find(d => d.name === this.datasetSelected); + } + + /** + * Returns URL to download file for the given format. + * + * @param {string} format - The file format to download. + * + * @return string + */ + getUrl(format: string): string { + let query: string = `${getHost(this.appConfig.apiUrl)}/search/${this.datasetSelected}?a=${this.outputList.join(';')}`; + if (this.criteriaList.length > 0) { + query += `&c=${this.criteriaList.map(criterion => criterionToString(criterion)).join(';')};${this.getCriterionSelectedData()}`; + } else { + query += `&c=${this.getCriterionSelectedData()}`; + } + if (this.coneSearch) { + query += `&cs=${this.coneSearch.ra}:${this.coneSearch.dec}:${this.coneSearch.radius}`; + } + query += `&f=${format}`; + return query; + } + + getCriterionSelectedData() { + const attributeId = this.attributeList.find(a => a.search_flag === 'ID'); + return `${attributeId.id}::in::${this.selectedData.join('|')}`; + } + + /** + * Returns URL to download archive. + * + * @return boolean + */ + getUrlArchive(): string { + let query: string = `${getHost(this.appConfig.apiUrl)}/archive/${this.datasetSelected}?a=${this.outputList.join(';')}`; + if (this.criteriaList.length > 0) { + query += `&c=${this.criteriaList.map(criterion => criterionToString(criterion)).join(';')};${this.getCriterionSelectedData()}`; + } else { + query += `&c=${this.getCriterionSelectedData()}`; + } + if (this.coneSearch) { + query += `&cs=${this.coneSearch.ra}:${this.coneSearch.dec}:${this.coneSearch.radius}`; + } + return query; + } + + /** + * Emits event to action to broadcast data. + * + * @fires EventEmitter<string> + */ + broadcastVotable(): void { + this.broadcast.emit(this.getUrl('votable')); + } + + /** + * Allows to download file. + */ + click(event, href, extension): void { + event.preventDefault(); + + this.http.get(href, {responseType: "blob"}).subscribe( + data => { + let downloadLink = document.createElement('a'); + downloadLink.href = window.URL.createObjectURL(data); + downloadLink.setAttribute('download', `${this.datasetSelected}.${extension}`); + downloadLink.click(); + } + ); + } +} diff --git a/client/src/app/instance/search/components/result/datatable-tab.component.html b/client/src/app/instance/search/components/result/datatable-tab.component.html index f9f6b3aaa6a57531613c217cf6a1ebf3501bc5be..b0ad6b0c98a437955dc1be5f759df0ce1f640a94 100644 --- a/client/src/app/instance/search/components/result/datatable-tab.component.html +++ b/client/src/app/instance/search/components/result/datatable-tab.component.html @@ -8,6 +8,18 @@ <span *ngIf="!ag.isOpen"><span class="fas fa-chevron-down"></span></span> </span> </button> + <app-datatable-actions + [selectedData]="selectedData" + [datasetSelected]="datasetSelected" + [datasetList]="datasetList" + [attributeList]="attributeList" + [criteriaList]="criteriaList" + [outputList]="outputList" + [coneSearch]="coneSearch" + [dataLength]="dataLength" + [sampRegistered]="sampRegistered" + (broadcast)="broadcast.emit($event)"> + </app-datatable-actions> <app-datatable [dataset]="datasetList | datasetByName:datasetSelected" [instance]="instance" diff --git a/client/src/app/instance/search/components/result/datatable-tab.component.spec.ts b/client/src/app/instance/search/components/result/datatable-tab.component.spec.ts index 7705a42bee417a62090da3bbc6dceea580550981..3d261b62cf37b9689007c61b7de1a748793b5651 100644 --- a/client/src/app/instance/search/components/result/datatable-tab.component.spec.ts +++ b/client/src/app/instance/search/components/result/datatable-tab.component.spec.ts @@ -6,7 +6,7 @@ import { AccordionModule } from 'ngx-bootstrap/accordion'; import { DatatableTabComponent } from './datatable-tab.component'; import { Attribute, Dataset, Instance } from '../../../../metamodel/models'; -import { SearchQueryParams } from '../../../store/models'; +import { SearchQueryParams, Criterion, ConeSearch } from '../../../store/models'; import { DatasetByNamePipe } from '../../../../shared/pipes/dataset-by-name.pipe'; describe('[Instance][Search][Component][Result] DatatableTabComponent', () => { @@ -24,6 +24,19 @@ describe('[Instance][Search][Component][Result] DatatableTabComponent', () => { @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>; @@ -32,6 +45,7 @@ describe('[Instance][Search][Component][Result] DatatableTabComponent', () => { declarations: [ DatatableTabComponent, DatatableStubComponent, + DatatableActionsStubComponent, DatasetByNamePipe ], imports: [ diff --git a/client/src/app/instance/search/components/result/datatable-tab.component.ts b/client/src/app/instance/search/components/result/datatable-tab.component.ts index ff703b86206de32c28b2a9660077808926cd2eec..c70564ba46caa5d342e2c80c5516221d549dab58 100644 --- a/client/src/app/instance/search/components/result/datatable-tab.component.ts +++ b/client/src/app/instance/search/components/result/datatable-tab.component.ts @@ -10,7 +10,7 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { Instance, Attribute, Dataset } from 'src/app/metamodel/models'; -import { Pagination, SearchQueryParams } from 'src/app/instance/store/models'; +import { Pagination, SearchQueryParams, Criterion, ConeSearch } from 'src/app/instance/store/models'; /** * @class @@ -27,8 +27,11 @@ export class DatatableTabComponent { @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; @@ -36,4 +39,5 @@ export class DatatableTabComponent { @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(); } diff --git a/client/src/app/instance/search/components/result/index.ts b/client/src/app/instance/search/components/result/index.ts index f8e4657e1f14cc050b279326e6434fd79d50a862..fffbcf4e766b53c4b4788371155f5e08e7b22207 100644 --- a/client/src/app/instance/search/components/result/index.ts +++ b/client/src/app/instance/search/components/result/index.ts @@ -4,6 +4,7 @@ import { ReminderComponent } from './reminder.component'; import { SampComponent } from './samp.component'; import { UrlDisplayComponent } from './url-display.component'; import { DatatableComponent } from './datatable.component'; +import { DatatableActionsComponent } from './datatable-actions.component'; import { rendererComponents } from './renderer'; export const resultComponents = [ @@ -13,5 +14,6 @@ export const resultComponents = [ SampComponent, UrlDisplayComponent, DatatableComponent, + DatatableActionsComponent, rendererComponents ]; diff --git a/client/src/app/instance/search/containers/result.component.html b/client/src/app/instance/search/containers/result.component.html index 93a2c7ac44fcdfbdb4dc13730bab79ae612d7c41..6ab41d2cd824b5b049c07165a48da4f90bdd5428 100644 --- a/client/src/app/instance/search/containers/result.component.html +++ b/client/src/app/instance/search/containers/result.component.html @@ -65,16 +65,19 @@ [instance]="instance | async" [datasetList]="datasetList | async" [attributeList]="attributeList | async | sortByOutputDisplay" + [criteriaList]="criteriaList | async" [outputList]="outputList | async" [queryParams]="queryParams | async" [dataLength]="dataLength | async" + [sampRegistered]="sampRegistered | async" [data]="data | async" [dataIsLoading]="dataIsLoading | async" [dataIsLoaded]="dataIsLoaded | async" [selectedData]="selectedData | async" (retrieveData)="retrieveData($event)" (addSelectedData)="addSearchData($event)" - (deleteSelectedData)="deleteSearchData($event)"> + (deleteSelectedData)="deleteSearchData($event)" + (broadcast)="broadcastVotable($event)"> </app-datatable-tab> </ng-container> </div> diff --git a/client/src/app/instance/search/containers/result.component.spec.ts b/client/src/app/instance/search/containers/result.component.spec.ts index 16462a1f25a8131e4d79b1dc6dd559b616e0b53f..37153740f4e191821a4e55836b3d93e155a7b1ee 100644 --- a/client/src/app/instance/search/containers/result.component.spec.ts +++ b/client/src/app/instance/search/containers/result.component.spec.ts @@ -66,8 +66,11 @@ describe('[Instance][Search][Container] ResultComponent', () => { @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; diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index 84b7f5565f820eb0e18993c3fd967f7dd71b3b29..bfb8594e449030fea934eab9a1143c8ead325adb 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -55,6 +55,7 @@ import { sharedPipes } from './pipes'; CommonModule, FormsModule, ReactiveFormsModule, + BsDropdownModule, ModalModule, AccordionModule, PopoverModule, diff --git a/server/app/dependencies.php b/server/app/dependencies.php index 2ba1d1681c1f8c71cb792a2c0ff90614ef782108..9fe363d692e79627b5dc6aea9e37acf9bce6371c 100644 --- a/server/app/dependencies.php +++ b/server/app/dependencies.php @@ -203,7 +203,7 @@ $container->set('App\Action\SearchAction', function (ContainerInterface $c) { $container->set('App\Action\ArchiveAction', function (ContainerInterface $c) { $anisQueryBuilder = (new App\Search\Query\AnisQueryBuilder()) ->addQueryPart(new App\Search\Query\From()) - ->addQueryPart(new App\Search\Query\SelectFile()) + ->addQueryPart(new App\Search\Query\Select()) ->addQueryPart(new App\Search\Query\ConeSearch()) ->addQueryPart(new App\Search\Query\Where(new App\Search\Query\Operator\OperatorFactory())) ->addQueryPart(new App\Search\Query\Order()) diff --git a/server/src/Action/ArchiveAction.php b/server/src/Action/ArchiveAction.php index 431a3648685b4d5ae588af229e5448ab0586a0ed..a40045c1d12de8b5c144c003844b6edc6d79902d 100644 --- a/server/src/Action/ArchiveAction.php +++ b/server/src/Action/ArchiveAction.php @@ -150,7 +150,7 @@ final class ArchiveAction extends AbstractAction while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { foreach ($attributesSelected as $attribute) { $attributeLabel = $attribute->getLabel(); - $filePath = $this->dataPath . $dataset->getDataPath() . DIRECTORY_SEPARATOR . $row[$attributeLabel]; + $filePath = $this->dataPath . $dataset->getFullDataPath() . DIRECTORY_SEPARATOR . $row[$attributeLabel]; if (file_exists($filePath)) { $zip->addFile($filePath, $row[$attributeLabel]); } diff --git a/server/src/Search/Query/SelectFile.php b/server/src/Search/Query/SelectFile.php deleted file mode 100644 index 24d71445599a30058f1052b04043752e1fb71fa5..0000000000000000000000000000000000000000 --- a/server/src/Search/Query/SelectFile.php +++ /dev/null @@ -1,52 +0,0 @@ -<?php - -/* - * This file is part of Anis Server. - * - * (c) 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. - */ -declare(strict_types=1); - -namespace App\Search\Query; - -use App\Entity\Dataset; - -/** - * Represents the Anis Select File attributes Query Part - * - * @author François Agneray <francois.agneray@lam.fr> - * @package App\Search\Query - */ -class SelectFile extends AbstractQueryPart -{ - /** - * Adds the select clause to the request and set only attribute with search_flag = FILE - * - * @param AnisQueryBuilder $anisQueryBuilder Represents the query being built - * @param Dataset $dataset Represents the requested dataset - * @param string[] $queryParams The query params of the url (after ?) - */ - public function __invoke(AnisQueryBuilder $anisQueryBuilder, Dataset $dataset, array $queryParams): void - { - if ($queryParams['a'] === 'all') { - $listOfIds = array_map(fn($attribute) => $attribute->getId(), $dataset->getAttributes()); - } else { - $listOfIds = explode(';', $queryParams['a']); - } - - $columns = array(); - $attributes = array(); - foreach ($listOfIds as $id) { - $attribute = $this->getAttribute($dataset, (int) $id); - if ($attribute->getSearchFlag() === 'FILE') { - $columns[] = $dataset->getTableRef() . '.' . $attribute->getName() . ' as ' . $attribute->getLabel(); - $attributes[] = $attribute; - } - } - $anisQueryBuilder->getDoctrineQueryBuilder()->select($columns); - $anisQueryBuilder->setAttributesSelected($attributes); - } -}