diff --git a/client/src/app/admin/instance/dataset/components/file/add-file.component.html b/client/src/app/admin/instance/dataset/components/file/add-file.component.html new file mode 100644 index 0000000000000000000000000000000000000000..c43bbc8910f7b1a8f0f1411c85f898363b62c3de --- /dev/null +++ b/client/src/app/admin/instance/dataset/components/file/add-file.component.html @@ -0,0 +1,30 @@ +<div class="row mb-1"> + <div class="col-12"> + <button title="Add new file" (click)="openModal(template); $event.stopPropagation()" class="btn btn-outline-success"> + <span class="fas fa-plus"></span> New file + </button> + </div> +</div> + +<ng-template #template> + <div class="modal-header"> + <h4 class="modal-title pull-left"><strong>Add new file</strong></h4> + </div> + <div class="modal-body"> + <app-file-form + [instance]="instance" + [dataset]="dataset" + [files]="files" + [filesIsLoading]="filesIsLoading" + [filesIsLoaded]="filesIsLoaded" + (loadRootDirectory)="loadRootDirectory.emit($event)" + (onSubmit)="add.emit($event); modalRef.hide()" + #formNewFile> + <button [disabled]="!formNewFile.form.valid || formNewFile.form.pristine" type="submit" class="btn btn-primary"> + <span class="fa fa-database"></span> Add file + </button> + + <button (click)="modalRef.hide()" type="button" class="btn btn-danger">Cancel</button> + </app-file-form> + </div> +</ng-template> diff --git a/client/src/app/admin/instance/dataset/components/file/add-file.component.ts b/client/src/app/admin/instance/dataset/components/file/add-file.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..b89acd24d72abd21c8478e46f907eb24d9e51c96 --- /dev/null +++ b/client/src/app/admin/instance/dataset/components/file/add-file.component.ts @@ -0,0 +1,39 @@ +/** + * 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, ChangeDetectionStrategy, TemplateRef, Input, Output, EventEmitter } from '@angular/core'; + +import { BsModalService } from 'ngx-bootstrap/modal'; +import { BsModalRef } from 'ngx-bootstrap/modal/bs-modal-ref.service'; + +import { Dataset, File, Instance } from 'src/app/metamodel/models'; +import { FileInfo } from 'src/app/admin/store/models'; + +@Component({ + selector: 'app-add-file', + templateUrl: 'add-file.component.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class AddFileComponent { + @Input() instance: Instance; + @Input() dataset: Dataset; + @Input() files: FileInfo[]; + @Input() filesIsLoading: boolean; + @Input() filesIsLoaded: boolean; + @Output() loadRootDirectory: EventEmitter<string> = new EventEmitter(); + @Output() add: EventEmitter<File> = new EventEmitter(); + + modalRef: BsModalRef; + + constructor(private modalService: BsModalService) { } + + openModal(template: TemplateRef<any>) { + this.modalRef = this.modalService.show(template); + } +} diff --git a/client/src/app/admin/instance/dataset/components/file/edit-file.component.html b/client/src/app/admin/instance/dataset/components/file/edit-file.component.html new file mode 100644 index 0000000000000000000000000000000000000000..4323f423cec71dc2c948a0841091326013e22c9c --- /dev/null +++ b/client/src/app/admin/instance/dataset/components/file/edit-file.component.html @@ -0,0 +1,27 @@ +<button title="Edit this file" (click)="openModal(template); $event.stopPropagation()" class="btn btn-outline-primary"> + <span class="fas fa-edit"></span> +</button> + +<ng-template #template> + <div class="modal-header"> + <h4 class="modal-title pull-left"><strong>Edit file</strong></h4> + </div> + <div class="modal-body"> + <app-file-form + [file]="file" + [instance]="instance" + [dataset]="dataset" + [files]="files" + [filesIsLoading]="filesIsLoading" + [filesIsLoaded]="filesIsLoaded" + (loadRootDirectory)="loadRootDirectory.emit($event)" + (onSubmit)="edit.emit($event); modalRef.hide()" + #formEditFile> + <button [disabled]="!formEditFile.form.valid || formEditFile.form.pristine" type="submit" class="btn btn-primary"> + <span class="fa fa-database"></span> Update file information + </button> + + <button (click)="modalRef.hide()" type="button" class="btn btn-danger">Cancel</button> + </app-file-form> + </div> +</ng-template> diff --git a/client/src/app/admin/instance/dataset/components/file/edit-file.component.ts b/client/src/app/admin/instance/dataset/components/file/edit-file.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..927611edb3bca3785af265ec3cb109b468766d7a --- /dev/null +++ b/client/src/app/admin/instance/dataset/components/file/edit-file.component.ts @@ -0,0 +1,40 @@ +/** + * This file is part of Anis Client. + * + * @copyright Laboratoire d'Astrophysique de Marseille / CNRS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Component, Input, ChangeDetectionStrategy, TemplateRef, Output, EventEmitter } from '@angular/core'; + +import { BsModalService } from 'ngx-bootstrap/modal'; +import { BsModalRef } from 'ngx-bootstrap/modal/bs-modal-ref.service'; + +import { Dataset, File, Instance } from 'src/app/metamodel/models'; +import { FileInfo } from 'src/app/admin/store/models'; + +@Component({ + selector: 'app-edit-file', + templateUrl: 'edit-file.component.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class EditFileComponent { + @Input() file: File; + @Input() instance: Instance; + @Input() dataset: Dataset; + @Input() files: FileInfo[]; + @Input() filesIsLoading: boolean; + @Input() filesIsLoaded: boolean; + @Output() loadRootDirectory: EventEmitter<string> = new EventEmitter(); + @Output() edit: EventEmitter<File> = new EventEmitter(); + + modalRef: BsModalRef; + + constructor(private modalService: BsModalService) { } + + openModal(template: TemplateRef<any>) { + this.modalRef = this.modalService.show(template); + } +} diff --git a/client/src/app/admin/instance/dataset/components/file/file-form.component.html b/client/src/app/admin/instance/dataset/components/file/file-form.component.html new file mode 100644 index 0000000000000000000000000000000000000000..62e7075142d337137bf8df9a43253dfad1c99712 --- /dev/null +++ b/client/src/app/admin/instance/dataset/components/file/file-form.component.html @@ -0,0 +1,28 @@ +<form [formGroup]="form" (ngSubmit)="submit()" novalidate> + <app-path-select-form-control + [form]="form" + [controlName]="'file_path'" + [controlLabel]="'File path'" + [files]="files" + [filesIsLoading]="filesIsLoading" + [filesIsLoaded]="filesIsLoaded" + [selectType]="'file'" + (loadDirectory)="onChangeFileSelect($event)" + (select)="onFileSelect($event)"> + </app-path-select-form-control> + <div class="form-group"> + <label for="label">Label</label> + <input type="text" class="form-control" id="label" name="label" formControlName="label"> + </div> + <div class="form-group"> + <label for="file_size">File size</label> + <input type="number" class="form-control" id="file_size" name="file_size" formControlName="file_size"> + </div> + <div class="form-group"> + <label for="type">Type</label> + <input type="text" class="form-control" id="type" name="type" formControlName="type"> + </div> + <div class="form-group"> + <ng-content></ng-content> + </div> +</form> \ No newline at end of file diff --git a/client/src/app/admin/instance/dataset/components/file/file-form.component.ts b/client/src/app/admin/instance/dataset/components/file/file-form.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..04e83901ba876a97ddf191d82c59ac08647c509f --- /dev/null +++ b/client/src/app/admin/instance/dataset/components/file/file-form.component.ts @@ -0,0 +1,61 @@ +/** + * 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, OnInit, EventEmitter } from '@angular/core'; +import { FormGroup, FormControl, Validators } from '@angular/forms'; + +import { File, Dataset, Instance } from 'src/app/metamodel/models'; +import { FileInfo } from 'src/app/admin/store/models'; + +@Component({ + selector: 'app-file-form', + templateUrl: 'file-form.component.html' +}) +export class FileFormComponent implements OnInit { + @Input() file: File; + @Input() instance: Instance; + @Input() dataset: Dataset; + @Input() files: FileInfo[]; + @Input() filesIsLoading: boolean; + @Input() filesIsLoaded: boolean; + @Output() loadRootDirectory: EventEmitter<string> = new EventEmitter(); + @Output() onSubmit: EventEmitter<File> = new EventEmitter(); + + public form = new FormGroup({ + label: new FormControl('', [Validators.required]), + file_path: new FormControl('', [Validators.required]), + file_size: new FormControl('', [Validators.required]), + type: new FormControl('', [Validators.required]) + }); + + ngOnInit() { + if (this.file) { + this.form.patchValue(this.file); + } + } + + onChangeFileSelect(path: string) { + this.loadRootDirectory.emit(`${this.instance.data_path}${this.dataset.data_path}${path}`); + } + + onFileSelect(fileInfo: FileInfo) { + this.form.controls.file_size.setValue(fileInfo.size); + } + + submit() { + if (this.file) { + this.onSubmit.emit({ + ...this.file, + ...this.form.getRawValue() + }); + } else { + this.onSubmit.emit(this.form.getRawValue()); + } + } +} diff --git a/client/src/app/admin/instance/dataset/components/file/file-list.component.html b/client/src/app/admin/instance/dataset/components/file/file-list.component.html new file mode 100644 index 0000000000000000000000000000000000000000..c4d3ed92d36c9c27a261f0159f2aafe28389529a --- /dev/null +++ b/client/src/app/admin/instance/dataset/components/file/file-list.component.html @@ -0,0 +1,59 @@ +<app-add-file + [instance]="instance" + [dataset]="dataset" + [files]="files" + [filesIsLoading]="filesIsLoading" + [filesIsLoaded]="filesIsLoaded" + (loadRootDirectory)="loadRootDirectory.emit($event)" + (add)="add.emit($event)"> +</app-add-file> + +<div class="row" *ngIf="fileList.length === 0"> + <div class="col-12 lead text-center font-weight-bold"> + No files available... + </div> +</div> + +<div *ngIf="fileList.length > 0" class="table-responsive"> + <table class="table table-striped" aria-describedby="Image list"> + <thead> + <tr> + <th scope="col">ID</th> + <th scope="col">Label</th> + <th scope="col">File path</th> + <th scope="col">File size</th> + <th scope="col">Type</th> + <th scope="col">Edit</th> + <th scope="col">Delete</th> + </tr> + </thead> + <tbody> + <tr *ngFor="let file of fileList"> + <td class="align-middle">{{ file.id }}</td> + <td class="align-middle">{{ file.label }}</td> + <td class="align-middle">{{ file.file_path }}</td> + <td class="align-middle">{{ file.file_size | formatFileSize:false }}</td> + <td class="align-middle">{{ file.type }}</td> + <td class="align-middle"> + <app-edit-file + [file]="file" + [instance]="instance" + [dataset]="dataset" + [files]="files" + [filesIsLoading]="filesIsLoading" + [filesIsLoaded]="filesIsLoaded" + (loadRootDirectory)="loadRootDirectory.emit($event)" + (edit)="edit.emit($event)"> + </app-edit-file> + </td> + <td class="align-middle"> + <app-delete-btn + [type]="'file'" + [label]="file.label" + (confirm)="delete.emit(file)"> + </app-delete-btn> + </td> + </tr> + </tbody> + </table> +</div> \ No newline at end of file diff --git a/client/src/app/admin/instance/dataset/components/file/file-list.component.ts b/client/src/app/admin/instance/dataset/components/file/file-list.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..5d4ec5402c2be5c28179c07ef57b001c3e6a03d3 --- /dev/null +++ b/client/src/app/admin/instance/dataset/components/file/file-list.component.ts @@ -0,0 +1,22 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core'; + +import { Dataset, File, Instance } from 'src/app/metamodel/models'; +import { FileInfo } from 'src/app/admin/store/models'; + +@Component({ + selector: 'app-file-list', + templateUrl: 'file-list.component.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class FileListComponent { + @Input() fileList: File[]; + @Input() instance: Instance; + @Input() dataset: Dataset; + @Input() files: FileInfo[]; + @Input() filesIsLoading: boolean; + @Input() filesIsLoaded: boolean; + @Output() loadRootDirectory: EventEmitter<string> = new EventEmitter(); + @Output() add: EventEmitter<File> = new EventEmitter(); + @Output() edit: EventEmitter<File> = new EventEmitter(); + @Output() delete: EventEmitter<File> = new EventEmitter(); +} diff --git a/client/src/app/admin/instance/dataset/components/file/index.ts b/client/src/app/admin/instance/dataset/components/file/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..2c48a5fc6b29a5548f6a4f83b8e193116a63be58 --- /dev/null +++ b/client/src/app/admin/instance/dataset/components/file/index.ts @@ -0,0 +1,11 @@ +import { FileListComponent } from './file-list.component'; +import { FileFormComponent } from './file-form.component'; +import { AddFileComponent } from './add-file.component'; +import { EditFileComponent } from './edit-file.component'; + +export const fileComponents = [ + FileListComponent, + FileFormComponent, + AddFileComponent, + EditFileComponent +]; diff --git a/client/src/app/admin/instance/dataset/components/index.ts b/client/src/app/admin/instance/dataset/components/index.ts index 539033b11b2a14f59a55282faf5c3774763736e6..b28b04af274518cf75b5d4a0ffaf4e3e91654f87 100644 --- a/client/src/app/admin/instance/dataset/components/index.ts +++ b/client/src/app/admin/instance/dataset/components/index.ts @@ -12,6 +12,7 @@ import { criteriaFamilyComponents } from './criteria-family'; import { datasetComponents } from './dataset'; import { datasetFamilyComponents } from './dataset-family'; import { imageComponents } from './image'; +import { fileComponents } from './file'; import { coneSearchConfigComponents } from './cone-search-config'; import { outputCategoryComponents } from './output-category'; import { outputFamilyComponents } from './output-family'; @@ -23,6 +24,7 @@ export const dummiesComponents = [ datasetComponents, datasetFamilyComponents, imageComponents, + fileComponents, coneSearchConfigComponents, outputCategoryComponents, outputFamilyComponents, diff --git a/client/src/app/admin/instance/dataset/containers/configure-dataset.component.html b/client/src/app/admin/instance/dataset/containers/configure-dataset.component.html index e584c3b1a23ff22c8aa5668d83e2538910621e51..a556cfeec2fc0a658f1450919f778ed597696ed9 100644 --- a/client/src/app/admin/instance/dataset/containers/configure-dataset.component.html +++ b/client/src/app/admin/instance/dataset/containers/configure-dataset.component.html @@ -71,6 +71,9 @@ <li class="nav-item"> <a class="nav-link" routerLink="./" [queryParams]="{tab_selected: 'images'}" [ngClass]="{'active': (tabSelected | async) === 'images'}">Images</a> </li> + <li class="nav-item"> + <a class="nav-link" routerLink="./" [queryParams]="{tab_selected: 'files'}" [ngClass]="{'active': (tabSelected | async) === 'files'}">Files</a> + </li> <li class="nav-item"> <a class="nav-link" routerLink="./" [queryParams]="{tab_selected: 'cone-search'}" [ngClass]="{'active': (tabSelected | async) === 'cone-search'}">Cone-search</a> </li> @@ -178,6 +181,19 @@ (edit)="editImage($event)" (delete)="deleteImage($event)"> </app-image-list> + <app-file-list + *ngSwitchCase="'files'" + [fileList]="fileList | async" + [instance]="instance | async" + [dataset]="dataset | async" + [files]="files | async" + [filesIsLoading]="filesIsLoading | async" + [filesIsLoaded]="filesIsLoaded | async" + (loadRootDirectory)="loadRootDirectory($event)" + (add)="addFile($event)" + (edit)="editFile($event)" + (delete)="deleteFile($event)"> + </app-file-list> <app-cone-search-config *ngSwitchCase="'cone-search'" [dataset]="dataset | async" diff --git a/client/src/app/admin/instance/dataset/containers/configure-dataset.component.ts b/client/src/app/admin/instance/dataset/containers/configure-dataset.component.ts index 0b6e800da65824a360252e07282a87388b32fa7e..25e9fe5ffc246d11c9fc5cf5cd123d32a5461342 100644 --- a/client/src/app/admin/instance/dataset/containers/configure-dataset.component.ts +++ b/client/src/app/admin/instance/dataset/containers/configure-dataset.component.ts @@ -14,7 +14,7 @@ import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { Store } from '@ngrx/store'; -import { SelectOption, Instance, Dataset, Attribute, CriteriaFamily, OutputCategory, OutputFamily, Image, ConeSearchConfig } from 'src/app/metamodel/models'; +import { SelectOption, Instance, Dataset, Attribute, CriteriaFamily, OutputCategory, OutputFamily, Image, File, ConeSearchConfig } from 'src/app/metamodel/models'; import { Column, FileInfo, FitsImageLimits } from 'src/app/admin/store/models'; import * as instanceSelector from 'src/app/metamodel/selectors/instance.selector'; import * as datasetSelector from 'src/app/metamodel/selectors/dataset.selector'; @@ -33,6 +33,8 @@ import * as attributeDistinctActions from 'src/app/admin/store/actions/attribute import * as attributeDistinctSelector from 'src/app/admin/store/selectors/attribute-distinct.selector'; import * as imageActions from 'src/app/metamodel/actions/image.actions'; import * as imageSelector from 'src/app/metamodel/selectors/image.selector'; +import * as fileActions from 'src/app/metamodel/actions/file.actions'; +import * as fileSelector from 'src/app/metamodel/selectors/file.selector'; import * as adminFileExplorerActions from 'src/app/admin/store/actions/admin-file-explorer.actions'; import * as adminFileExplorerSelector from 'src/app/admin/store/selectors/admin-file-explorer.selector'; import * as fitsImageActions from 'src/app/admin/store/actions/fits-image.actions'; @@ -75,6 +77,9 @@ export class ConfigureDatasetComponent implements OnInit { public imageList: Observable<Image[]>; public imageListIsLoading: Observable<boolean>; public imageListIsLoaded: Observable<boolean>; + public fileList: Observable<File[]>; + public fileListIsLoading: Observable<boolean>; + public fileListIsLoaded: Observable<boolean>; public files: Observable<FileInfo[]>; public filesIsLoading: Observable<boolean>; public filesIsLoaded: Observable<boolean>; @@ -114,6 +119,9 @@ export class ConfigureDatasetComponent implements OnInit { this.imageList = store.select(imageSelector.selectAllImages); this.imageListIsLoading = store.select(imageSelector.selectImageListIsLoading); this.imageListIsLoaded = store.select(imageSelector.selectImageListIsLoaded); + this.fileList = store.select(fileSelector.selectAllFiles); + this.fileListIsLoading = store.select(fileSelector.selectFileListIsLoading); + this.fileListIsLoaded = store.select(fileSelector.selectFileListIsLoaded); this.files = store.select(adminFileExplorerSelector.selectFiles); this.filesIsLoading = store.select(adminFileExplorerSelector.selectFilesIsLoading); this.filesIsLoaded = store.select(adminFileExplorerSelector.selectFilesIsLoaded); @@ -217,6 +225,18 @@ export class ConfigureDatasetComponent implements OnInit { this.store.dispatch(imageActions.deleteImage({ image })); } + addFile(file: File) { + this.store.dispatch(fileActions.addFile({ file })); + } + + editFile(file: File) { + this.store.dispatch(fileActions.editFile({ file })); + } + + deleteFile(file: File) { + this.store.dispatch(fileActions.deleteFile({ file })); + } + addConeSearchConfig(coneSearchConfig: ConeSearchConfig) { this.store.dispatch(coneSearchConfigActions.addConeSearchConfig({ coneSearchConfig })); } diff --git a/client/src/app/metamodel/actions/file.actions.ts b/client/src/app/metamodel/actions/file.actions.ts new file mode 100644 index 0000000000000000000000000000000000000000..3c8b0b0bfc4f6dae6b0589a8b3293ec6f2d6372e --- /dev/null +++ b/client/src/app/metamodel/actions/file.actions.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 { createAction, props } from '@ngrx/store'; + +import { File } from '../models'; + +export const loadFileList = createAction('[Metamodel] Load File List'); +export const loadFileListSuccess = createAction('[Metamodel] Load File List Success', props<{ files: File[] }>()); +export const loadFileListFail = createAction('[Metamodel] Load File List Fail'); +export const addFile = createAction('[Metamodel] Add File', props<{ file: File }>()); +export const addFileSuccess = createAction('[Metamodel] Add File Success', props<{ file: File }>()); +export const addFileFail = createAction('[Metamodel] Add File Fail'); +export const editFile = createAction('[Metamodel] Edit File', props<{ file: File }>()); +export const editFileSuccess = createAction('[Metamodel] Edit File Success', props<{ file: File }>()); +export const editFileFail = createAction('[Metamodel] Edit File Fail'); +export const deleteFile = createAction('[Metamodel] Delete File', props<{ file: File }>()); +export const deleteFileSuccess = createAction('[Metamodel] Delete File Success', props<{ file: File }>()); +export const deleteFileFail = createAction('[Metamodel] Delete File Fail'); \ No newline at end of file diff --git a/client/src/app/metamodel/effects/file.effects.ts b/client/src/app/metamodel/effects/file.effects.ts new file mode 100644 index 0000000000000000000000000000000000000000..1923f3af2b5d07eb604aae3f1d78f61422f84df5 --- /dev/null +++ b/client/src/app/metamodel/effects/file.effects.ts @@ -0,0 +1,156 @@ +/** + * 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 { Injectable } from '@angular/core'; + +import { Actions, createEffect, ofType, concatLatestFrom } from '@ngrx/effects'; +import { Store } from '@ngrx/store'; +import { of } from 'rxjs'; +import { map, tap, mergeMap, catchError } from 'rxjs/operators'; +import { ToastrService } from 'ngx-toastr'; + +import * as fileActions from '../actions/file.actions'; +import { FileService } from '../services/file.service'; +import * as datasetSelector from '../selectors/dataset.selector'; + +/** + * @class + * @classdesc File effects. + */ +@Injectable() +export class FileEffects { + /** + * Calls action to retrieve file list. + */ + loadFiles$ = createEffect((): any => + this.actions$.pipe( + ofType(fileActions.loadFileList), + concatLatestFrom(() => this.store.select(datasetSelector.selectDatasetNameByRoute)), + mergeMap(([, datasetName]) => this.fileService.retrieveFileList(datasetName) + .pipe( + map(files => fileActions.loadFileListSuccess({ files })), + catchError(() => of(fileActions.loadFileListFail())) + ) + ) + ) + ); + + /** + * Calls action to add an file. + */ + addFile$ = createEffect((): any => + this.actions$.pipe( + ofType(fileActions.addFile), + concatLatestFrom(() => this.store.select(datasetSelector.selectDatasetNameByRoute)), + mergeMap(([action, datasetName]) => this.fileService.addFile(datasetName, action.file) + .pipe( + map(file => fileActions.addFileSuccess({ file })), + catchError(() => of(fileActions.addFileFail())) + ) + ) + ) + ); + + /** + * Displays add file success notification. + */ + addFileSuccess$ = createEffect(() => + this.actions$.pipe( + ofType(fileActions.addFileSuccess), + tap(() => this.toastr.success('File successfully added', 'The new file was added into the database')) + ), { dispatch: false } + ); + + /** + * Displays add file error notification. + */ + addFileFail$ = createEffect(() => + this.actions$.pipe( + ofType(fileActions.addFileFail), + tap(() => this.toastr.error('Failure to add file', 'The new file could not be added into the database')) + ), { dispatch: false } + ); + + /** + * Calls action to modify an file. + */ + editFile$ = createEffect((): any => + this.actions$.pipe( + ofType(fileActions.editFile), + mergeMap(action => this.fileService.editFile(action.file) + .pipe( + map(file => fileActions.editFileSuccess({ file })), + catchError(() => of(fileActions.editFileFail())) + ) + ) + ) + ); + + /** + * Displays edit file success notification. + */ + editFileSuccess$ = createEffect(() => + this.actions$.pipe( + ofType(fileActions.editFileSuccess), + tap(() => this.toastr.success('File successfully edited', 'The existing file has been edited into the database')) + ), { dispatch: false } + ); + + /** + * Displays edit file error notification. + */ + editFileFail$ = createEffect(() => + this.actions$.pipe( + ofType(fileActions.editFileFail), + tap(() => this.toastr.error('Failure to edit file', 'The existing file could not be edited into the database')) + ), { dispatch: false } + ); + + /** + * Calls action to remove an file. + */ + deleteFile$ = createEffect((): any => + this.actions$.pipe( + ofType(fileActions.deleteFile), + mergeMap(action => this.fileService.deleteFile(action.file.id) + .pipe( + map(() => fileActions.deleteFileSuccess({ file: action.file })), + catchError(() => of(fileActions.deleteFileFail())) + ) + ) + ) + ); + + /** + * Displays delete file success notification. + */ + deleteFileSuccess$ = createEffect(() => + this.actions$.pipe( + ofType(fileActions.deleteFileSuccess), + tap(() => this.toastr.success('File successfully deleted', 'The existing file has been deleted')) + ), { dispatch: false } + ); + + /** + * Displays delete file error notification. + */ + deleteFileFail$ = createEffect(() => + this.actions$.pipe( + ofType(fileActions.deleteFileFail), + tap(() => this.toastr.error('Failure to delete file', 'The existing file could not be deleted from the database')) + ), { dispatch: false } + ); + + constructor( + private actions$: Actions, + private fileService: FileService, + private toastr: ToastrService, + private store: Store<{ }> + ) {} +} diff --git a/client/src/app/metamodel/effects/index.ts b/client/src/app/metamodel/effects/index.ts index 61eaad29e53e1ab5b57e15f5a93f911a56b6103a..64961cd4777e5230daa9cf0592578673aa4632d9 100644 --- a/client/src/app/metamodel/effects/index.ts +++ b/client/src/app/metamodel/effects/index.ts @@ -20,6 +20,7 @@ import { OutputFamilyEffects } from './output-family.effects'; import { SelectEffects } from './select.effects'; import { SelectOptionEffects } from './select-option.effects'; import { ImageEffects } from './image.effects'; +import { FileEffects } from './file.effects'; import { ConeSearchConfigEffects } from './cone-search-config.effects' export const metamodelEffects = [ @@ -36,5 +37,6 @@ export const metamodelEffects = [ SelectEffects, SelectOptionEffects, ImageEffects, + FileEffects, ConeSearchConfigEffects ]; diff --git a/client/src/app/metamodel/metamodel.reducer.ts b/client/src/app/metamodel/metamodel.reducer.ts index 3ea7e83aecb421c5cef5191a31750e5e83810634..8bbd0bc2c3cfd6709c92fc65c513a057dd117206 100644 --- a/client/src/app/metamodel/metamodel.reducer.ts +++ b/client/src/app/metamodel/metamodel.reducer.ts @@ -23,6 +23,7 @@ import * as outputFamily from './reducers/output-family.reducer'; import * as select from './reducers/select.reducer'; import * as selectOption from './reducers/select-option.reducer'; import * as image from './reducers/image.reducer'; +import * as file from './reducers/file.reducer'; import * as coneSearchConfig from './reducers/cone-search-config.reducer'; /** @@ -44,6 +45,7 @@ export interface State { select: select.State; selectOption: selectOption.State; image: image.State; + file: file.State; coneSearchConfig: coneSearchConfig.State; } @@ -61,6 +63,7 @@ const reducers = { select: select.selectReducer, selectOption: selectOption.selectOptionReducer, image: image.imageReducer, + file: file.fileReducer, coneSearchConfig: coneSearchConfig.coneSearchConfigReducer }; diff --git a/client/src/app/metamodel/models/file.model.ts b/client/src/app/metamodel/models/file.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..805d4cf17737b2956540930bac7ee468ddaf4a9f --- /dev/null +++ b/client/src/app/metamodel/models/file.model.ts @@ -0,0 +1,21 @@ +/** + * This file is part of Anis Client. + * + * @copyright Laboratoire d'Astrophysique de Marseille / CNRS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +/** + * Interface for file + * + * @interface File + */ +export interface File { + id: number; + label: string; + file_path: string; + file_size: number; + type: string; +} diff --git a/client/src/app/metamodel/models/index.ts b/client/src/app/metamodel/models/index.ts index fc907f1cfc10aceec322ce3cd9ae85b520357b23..e0f6870def5b2349a3d201e7e714c8cdef12f93d 100644 --- a/client/src/app/metamodel/models/index.ts +++ b/client/src/app/metamodel/models/index.ts @@ -24,3 +24,4 @@ export * from './image.model'; export * from './renderers'; export * from './detail-renderers'; export * from './cone-search-config.model'; +export * from './file.model'; \ No newline at end of file diff --git a/client/src/app/metamodel/reducers/file.reducer.ts b/client/src/app/metamodel/reducers/file.reducer.ts new file mode 100644 index 0000000000000000000000000000000000000000..fd7c428c4e1f5fb8bcd1dcb57128385e8938ae1f --- /dev/null +++ b/client/src/app/metamodel/reducers/file.reducer.ts @@ -0,0 +1,84 @@ +/** + * 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 { createReducer, on } from '@ngrx/store'; +import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity'; + +import { File } from '../models'; +import * as fileActions from '../actions/file.actions'; + +/** + * Interface for file state. + * + * @interface State + */ +export interface State extends EntityState<File> { + fileListIsLoading: boolean; + fileListIsLoaded: boolean; +} + +export const adapter: EntityAdapter<File> = createEntityAdapter<File>({ + selectId: (file: File) => file.id, + sortComparer: (a: File, b: File) => a.id - b.id +}); + +export const initialState: State = adapter.getInitialState({ + fileListIsLoading: false, + fileListIsLoaded: false +}); + +export const fileReducer = createReducer( + initialState, + on(fileActions.loadFileList, (state) => { + return { + ...state, + fileListIsLoading: true + } + }), + on(fileActions.loadFileListSuccess, (state, { files }) => { + return adapter.setAll( + files, + { + ...state, + fileListIsLoading: false, + fileListIsLoaded: true + } + ); + }), + on(fileActions.loadFileListFail, (state) => { + return { + ...state, + fileListIsLoading: false + } + }), + on(fileActions.addFileSuccess, (state, { file }) => { + return adapter.addOne(file, state) + }), + on(fileActions.editFileSuccess, (state, { file }) => { + return adapter.setOne(file, state) + }), + on(fileActions.deleteFileSuccess, (state, { file }) => { + return adapter.removeOne(file.id, state) + }) +); + +const { + selectIds, + selectEntities, + selectAll, + selectTotal, +} = adapter.getSelectors(); + +export const selectFileIds = selectIds; +export const selectFileEntities = selectEntities; +export const selectAllFiles = selectAll; +export const selectFileTotal = selectTotal; + +export const selectFileListIsLoading = (state: State) => state.fileListIsLoading; +export const selectFileListIsLoaded = (state: State) => state.fileListIsLoaded; diff --git a/client/src/app/metamodel/selectors/file.selector.ts b/client/src/app/metamodel/selectors/file.selector.ts new file mode 100644 index 0000000000000000000000000000000000000000..647e397bc7fff929946a1779aec414f1644efeb1 --- /dev/null +++ b/client/src/app/metamodel/selectors/file.selector.ts @@ -0,0 +1,54 @@ +/** + * 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 { createSelector } from '@ngrx/store'; + +import * as reducer from '../metamodel.reducer'; +import * as fromFile from '../reducers/file.reducer'; + +export const selectFileState = createSelector( + reducer.getMetamodelState, + (state: reducer.State) => state.file +); + +export const selectFileIds = createSelector( + selectFileState, + fromFile.selectFileIds +); + +export const selectFileEntities = createSelector( + selectFileState, + fromFile.selectFileEntities +); + +export const selectAllFiles = createSelector( + selectFileState, + fromFile.selectAllFiles +); + +export const selectFileTotal = createSelector( + selectFileState, + fromFile.selectFileTotal +); + +export const selectFileListIsLoading = createSelector( + selectFileState, + fromFile.selectFileListIsLoading +); + +export const selectFileListIsLoaded = createSelector( + selectFileState, + fromFile.selectFileListIsLoaded +); + +export const selectFileByRouteId = createSelector( + selectFileEntities, + reducer.selectRouterState, + (entities, router) => entities[router.state.params.id] +); \ No newline at end of file diff --git a/client/src/app/metamodel/services/file.service.ts b/client/src/app/metamodel/services/file.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..49536895e652a95de6deb13c2b053cc89921f2bc --- /dev/null +++ b/client/src/app/metamodel/services/file.service.ts @@ -0,0 +1,70 @@ +/** + * 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 { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; + +import { Observable } from 'rxjs'; + +import { File } from '../models'; +import { AppConfigService } from 'src/app/app-config.service'; + +/** + * @class + * @classdesc File service. + */ +@Injectable() +export class FileService { + constructor(private http: HttpClient, private config: AppConfigService) { } + + /** + * Retrieves file list for the given dataset. + * + * @param {string} datasetName - The dataset name. + * + * @return Observable<File[]> + */ + retrieveFileList(datasetName: string): Observable<File[]> { + return this.http.get<File[]>(`${this.config.apiUrl}/dataset/${datasetName}/file`); + } + + /** + * Adds a new file for the given dataset. + * + * @param {string} datasetName - The dataset name. + * @param {File} file - The file. + * + * @return Observable<File> + */ + addFile(datasetName: string, newFile: File): Observable<File> { + return this.http.post<File>(`${this.config.apiUrl}/dataset/${datasetName}/file`, newFile); + } + + /** + * Modifies an file. + * + * @param {File} file - The file. + * + * @return Observable<File> + */ + editFile(file: File): Observable<File> { + return this.http.put<File>(`${this.config.apiUrl}/file/${file.id}`, file); + } + + /** + * Removes an file. + * + * @param {number} fileId - The file ID. + * + * @return Observable<object> + */ + deleteFile(fileId: number): Observable<object> { + return this.http.delete(`${this.config.apiUrl}/file/${fileId}`); + } +} diff --git a/client/src/app/metamodel/services/index.ts b/client/src/app/metamodel/services/index.ts index 3556bae114714e3bb59f1827406b2d6f6cc6114e..09bc41998f8e4b0fc94a201eaba90073e0402587 100644 --- a/client/src/app/metamodel/services/index.ts +++ b/client/src/app/metamodel/services/index.ts @@ -20,6 +20,7 @@ import { OutputFamilyService } from './output-family.service'; import { SelectService } from './select.service'; import { SelectOptionService } from './select-option.service'; import { ImageService } from './image.service'; +import { FileService } from './file.service'; import { ConeSearchConfigService } from './cone-search-config.service'; export const metamodelServices = [ @@ -36,5 +37,6 @@ export const metamodelServices = [ SelectService, SelectOptionService, ImageService, + FileService, ConeSearchConfigService ]; diff --git a/server/app/dependencies.php b/server/app/dependencies.php index f210549049573958cede080d8ffb581d3e325e36..2ae9343cd21f981912d86842963e3b064278d3ee 100644 --- a/server/app/dependencies.php +++ b/server/app/dependencies.php @@ -209,6 +209,14 @@ $container->set('App\Action\ImageAction', function (ContainerInterface $c) { return new App\Action\ImageAction($c->get('em')); }); +$container->set('App\Action\FileListAction', function (ContainerInterface $c) { + return new App\Action\FileListAction($c->get('em')); +}); + +$container->set('App\Action\FileAction', function (ContainerInterface $c) { + return new App\Action\FileAction($c->get('em')); +}); + $container->set('App\Action\ConeSearchConfigAction', function (ContainerInterface $c) { return new App\Action\ConeSearchConfigAction($c->get('em')); }); diff --git a/server/app/routes.php b/server/app/routes.php index 928ca2f72b35c9183f4260c5e05847f5407c4c37..2b6715908160e51c1ca4dfbd9c8d8e8ac0b44464 100644 --- a/server/app/routes.php +++ b/server/app/routes.php @@ -67,6 +67,8 @@ $app->group('', function (RouteCollectorProxy $group) { ); $group->map([OPTIONS, GET, POST], '/dataset/{name}/image', App\Action\ImageListAction::class); $group->map([OPTIONS, GET, PUT, DELETE], '/image/{id}', App\Action\ImageAction::class); + $group->map([OPTIONS, GET, POST], '/dataset/{name}/file', App\Action\FileListAction::class); + $group->map([OPTIONS, GET, PUT, DELETE], '/file/{id}', App\Action\FileAction::class); $group->map([OPTIONS, GET, POST, PUT], '/dataset/{name}/cone-search-config', App\Action\ConeSearchConfigAction::class); })->add(new App\Middleware\RouteGuardMiddleware( boolval($container->get(SETTINGS)['token']['enabled']), diff --git a/server/doctrine-proxy/__CG__AppEntityFile.php b/server/doctrine-proxy/__CG__AppEntityFile.php new file mode 100644 index 0000000000000000000000000000000000000000..0ec08809bcadb2ff86544ab143e459cff4f3373f --- /dev/null +++ b/server/doctrine-proxy/__CG__AppEntityFile.php @@ -0,0 +1,294 @@ +<?php + +namespace DoctrineProxies\__CG__\App\Entity; + + +/** + * DO NOT EDIT THIS FILE - IT WAS CREATED BY DOCTRINE'S PROXY GENERATOR + */ +class File extends \App\Entity\File implements \Doctrine\ORM\Proxy\Proxy +{ + /** + * @var \Closure the callback responsible for loading properties in the proxy object. This callback is called with + * three parameters, being respectively the proxy object to be initialized, the method that triggered the + * initialization process and an array of ordered parameters that were passed to that method. + * + * @see \Doctrine\Common\Proxy\Proxy::__setInitializer + */ + public $__initializer__; + + /** + * @var \Closure the callback responsible of loading properties that need to be copied in the cloned object + * + * @see \Doctrine\Common\Proxy\Proxy::__setCloner + */ + public $__cloner__; + + /** + * @var boolean flag indicating if this object was already initialized + * + * @see \Doctrine\Persistence\Proxy::__isInitialized + */ + public $__isInitialized__ = false; + + /** + * @var array<string, null> properties to be lazy loaded, indexed by property name + */ + public static $lazyPropertiesNames = array ( +); + + /** + * @var array<string, mixed> default values of properties to be lazy loaded, with keys being the property names + * + * @see \Doctrine\Common\Proxy\Proxy::__getLazyProperties + */ + public static $lazyPropertiesDefaults = array ( +); + + + + public function __construct(?\Closure $initializer = null, ?\Closure $cloner = null) + { + + $this->__initializer__ = $initializer; + $this->__cloner__ = $cloner; + } + + + + + + + + /** + * + * @return array + */ + public function __sleep() + { + if ($this->__isInitialized__) { + return ['__isInitialized__', 'id', 'label', 'filePath', 'fileSize', 'type', 'dataset']; + } + + return ['__isInitialized__', 'id', 'label', 'filePath', 'fileSize', 'type', 'dataset']; + } + + /** + * + */ + public function __wakeup() + { + if ( ! $this->__isInitialized__) { + $this->__initializer__ = function (File $proxy) { + $proxy->__setInitializer(null); + $proxy->__setCloner(null); + + $existingProperties = get_object_vars($proxy); + + foreach ($proxy::$lazyPropertiesDefaults as $property => $defaultValue) { + if ( ! array_key_exists($property, $existingProperties)) { + $proxy->$property = $defaultValue; + } + } + }; + + } + } + + /** + * + */ + public function __clone() + { + $this->__cloner__ && $this->__cloner__->__invoke($this, '__clone', []); + } + + /** + * Forces initialization of the proxy + */ + public function __load() + { + $this->__initializer__ && $this->__initializer__->__invoke($this, '__load', []); + } + + /** + * {@inheritDoc} + * @internal generated method: use only when explicitly handling proxy specific loading logic + */ + public function __isInitialized() + { + return $this->__isInitialized__; + } + + /** + * {@inheritDoc} + * @internal generated method: use only when explicitly handling proxy specific loading logic + */ + public function __setInitialized($initialized) + { + $this->__isInitialized__ = $initialized; + } + + /** + * {@inheritDoc} + * @internal generated method: use only when explicitly handling proxy specific loading logic + */ + public function __setInitializer(\Closure $initializer = null) + { + $this->__initializer__ = $initializer; + } + + /** + * {@inheritDoc} + * @internal generated method: use only when explicitly handling proxy specific loading logic + */ + public function __getInitializer() + { + return $this->__initializer__; + } + + /** + * {@inheritDoc} + * @internal generated method: use only when explicitly handling proxy specific loading logic + */ + public function __setCloner(\Closure $cloner = null) + { + $this->__cloner__ = $cloner; + } + + /** + * {@inheritDoc} + * @internal generated method: use only when explicitly handling proxy specific cloning logic + */ + public function __getCloner() + { + return $this->__cloner__; + } + + /** + * {@inheritDoc} + * @internal generated method: use only when explicitly handling proxy specific loading logic + * @deprecated no longer in use - generated code now relies on internal components rather than generated public API + * @static + */ + public function __getLazyProperties() + { + return self::$lazyPropertiesDefaults; + } + + + /** + * {@inheritDoc} + */ + public function getId() + { + if ($this->__isInitialized__ === false) { + return (int) parent::getId(); + } + + + $this->__initializer__ && $this->__initializer__->__invoke($this, 'getId', []); + + return parent::getId(); + } + + /** + * {@inheritDoc} + */ + public function getLabel() + { + + $this->__initializer__ && $this->__initializer__->__invoke($this, 'getLabel', []); + + return parent::getLabel(); + } + + /** + * {@inheritDoc} + */ + public function setLabel($label) + { + + $this->__initializer__ && $this->__initializer__->__invoke($this, 'setLabel', [$label]); + + return parent::setLabel($label); + } + + /** + * {@inheritDoc} + */ + public function getFilePath() + { + + $this->__initializer__ && $this->__initializer__->__invoke($this, 'getFilePath', []); + + return parent::getFilePath(); + } + + /** + * {@inheritDoc} + */ + public function setFilePath($filePath) + { + + $this->__initializer__ && $this->__initializer__->__invoke($this, 'setFilePath', [$filePath]); + + return parent::setFilePath($filePath); + } + + /** + * {@inheritDoc} + */ + public function getFileSize() + { + + $this->__initializer__ && $this->__initializer__->__invoke($this, 'getFileSize', []); + + return parent::getFileSize(); + } + + /** + * {@inheritDoc} + */ + public function setFileSize($fileSize) + { + + $this->__initializer__ && $this->__initializer__->__invoke($this, 'setFileSize', [$fileSize]); + + return parent::setFileSize($fileSize); + } + + /** + * {@inheritDoc} + */ + public function getType() + { + + $this->__initializer__ && $this->__initializer__->__invoke($this, 'getType', []); + + return parent::getType(); + } + + /** + * {@inheritDoc} + */ + public function setType($type) + { + + $this->__initializer__ && $this->__initializer__->__invoke($this, 'setType', [$type]); + + return parent::setType($type); + } + + /** + * {@inheritDoc} + */ + public function jsonSerialize(): array + { + + $this->__initializer__ && $this->__initializer__->__invoke($this, 'jsonSerialize', []); + + return parent::jsonSerialize(); + } + +} diff --git a/server/src/Action/FileAction.php b/server/src/Action/FileAction.php new file mode 100644 index 0000000000000000000000000000000000000000..1396179471eff58a8c9e36ba28d7c7459cc9ba95 --- /dev/null +++ b/server/src/Action/FileAction.php @@ -0,0 +1,112 @@ +<?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\Action; + +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\ResponseInterface; +use Slim\Exception\HttpBadRequestException; +use Slim\Exception\HttpNotFoundException; +use App\Entity\File; + +/** + * @author François Agneray <francois.agneray@lam.fr> + * @package App\Action + */ +final class FileAction extends AbstractAction +{ + /** + * `GET` Returns the file found + * `PUT` Full update the file and returns the new version + * `DELETE` Delete the file found and return a confirmation message + * + * @param ServerRequestInterface $request PSR-7 This object represents the HTTP request + * @param ResponseInterface $response PSR-7 This object represents the HTTP response + * @param string[] $args This table contains information transmitted in the URL (see routes.php) + * + * @return ResponseInterface + */ + public function __invoke( + ServerRequestInterface $request, + ResponseInterface $response, + array $args + ): ResponseInterface { + if ($request->getMethod() === OPTIONS) { + return $response->withHeader('Access-Control-Allow-Methods', 'GET, PUT, DELETE, OPTIONS'); + } + + // Search the correct file with primary key + $file = $this->em->find('App\Entity\File', $args['id']); + + // If file is not found 404 + if (is_null($file)) { + throw new HttpNotFoundException( + $request, + 'File with id ' . $args['id'] . ' is not found' + ); + } + + if ($request->getMethod() === GET) { + $payload = json_encode($file); + } + + if ($request->getMethod() === PUT) { + $parsedBody = $request->getParsedBody(); + + $fields = array( + 'label', + 'file_path', + 'file_size', + 'type' + ); + + foreach ($fields as $a) { + if (!array_key_exists($a, $parsedBody)) { + throw new HttpBadRequestException( + $request, + 'Param ' . $a . ' needed to edit the file' + ); + } + } + + $this->editFile($file, $parsedBody); + $payload = json_encode($file); + } + + if ($request->getMethod() === DELETE) { + $id = $file->getId(); + $this->em->remove($file); + $this->em->flush(); + $payload = json_encode(array( + 'message' => 'File with id ' . $id . ' is removed!' + )); + } + + $response->getBody()->write($payload); + return $response; + } + + /** + * Update file object with setters + * + * @param File $file The file to update + * @param string[] $parsedBody Contains the new values ​​of the file sent by the user + */ + private function editFile(File $file, array $parsedBody): void + { + $file->setLabel($parsedBody['label']); + $file->setFilePath($parsedBody['file_path']); + $file->setFileSize($parsedBody['file_size']); + $file->setType($parsedBody['type']); + $this->em->flush(); + } +} diff --git a/server/src/Action/FileListAction.php b/server/src/Action/FileListAction.php new file mode 100644 index 0000000000000000000000000000000000000000..d9d7955011ac98626039b796fd39fc1808a96ee6 --- /dev/null +++ b/server/src/Action/FileListAction.php @@ -0,0 +1,113 @@ +<?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\Action; + +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\ResponseInterface; +use Slim\Exception\HttpBadRequestException; +use Slim\Exception\HttpNotFoundException; +use App\Entity\Dataset; +use App\Entity\File; + +/** + * @author François Agneray <francois.agneray@lam.fr> + * @package App\Action + */ +final class FileListAction extends AbstractAction +{ + /** + * `GET` Returns a list of all files for a given dataset + * `POST` Add a new file to a given dataset + * + * @param ServerRequestInterface $request PSR-7 This object represents the HTTP request + * @param ResponseInterface $response PSR-7 This object represents the HTTP response + * @param string[] $args This table contains information transmitted in the URL (see routes.php) + * + * @return ResponseInterface + */ + public function __invoke( + ServerRequestInterface $request, + ResponseInterface $response, + array $args + ): ResponseInterface { + if ($request->getMethod() === OPTIONS) { + return $response->withHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + } + + $dataset = $this->em->find('App\Entity\Dataset', $args['name']); + + // Returns HTTP 404 if the dataset is not found + if (is_null($dataset)) { + throw new HttpNotFoundException( + $request, + 'Dataset with name ' . $args['name'] . ' is not found' + ); + } + + if ($request->getMethod() === GET) { + $files = $this->em->getRepository('App\Entity\File')->findBy( + array('dataset' => $dataset), + array('id' => 'ASC') + ); + $payload = json_encode($files); + } + + if ($request->getMethod() === POST) { + $parsedBody = $request->getParsedBody(); + + $fields = array( + 'label', + 'file_path', + 'file_size', + 'type' + ); + + // To work this action needs information + foreach ($fields as $a) { + if (!array_key_exists($a, $parsedBody)) { + throw new HttpBadRequestException( + $request, + 'Param ' . $a . ' needed to add a new file' + ); + } + } + + $file = $this->postFile($parsedBody, $dataset); + $payload = json_encode($file); + $response = $response->withStatus(201); + } + + $response->getBody()->write($payload); + return $response; + } + + /** + * @param array $parsedBody Contains the values ​​of the new file sent by the user + * @param Dataset $dataset Dataset for adding the file + * + * @return File + */ + private function postFile(array $parsedBody, Dataset $dataset): File + { + $file = new File($dataset); + $file->setLabel($parsedBody['label']); + $file->setFilePath($parsedBody['file_path']); + $file->setFileSize($parsedBody['file_size']); + $file->setType($parsedBody['type']); + + $this->em->persist($file); + $this->em->flush(); + + return $file; + } +} diff --git a/server/src/Entity/File.php b/server/src/Entity/File.php new file mode 100644 index 0000000000000000000000000000000000000000..dddd8113e6e973c6ccc8522948f548a54d9f9191 --- /dev/null +++ b/server/src/Entity/File.php @@ -0,0 +1,129 @@ +<?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\Entity; + +/** + * @author François Agneray <francois.agneray@lam.fr> + * @package App\Entity + * + * @Entity + * @Table(name="file") + */ +class File implements \JsonSerializable +{ + /** + * @var int + * + * @Id + * @Column(type="integer", nullable=false) + * @GeneratedValue + */ + protected $id; + + /** + * @var string + * + * @Column(type="string", name="label", nullable=false) + */ + protected $label; + + /** + * @var string + * + * @Column(type="string", name="file_path", nullable=false) + */ + protected $filePath; + + /** + * @var integer + * + * @Column(type="integer", name="file_size", nullable=false) + */ + protected $fileSize; + + /** + * @var string + * + * @Column(type="string", name="type", nullable=false) + */ + protected $type; + + /** + * @var Dataset + * + * @ManyToOne(targetEntity="Dataset") + * @JoinColumn(name="dataset_name", referencedColumnName="name", nullable=false) + */ + protected $dataset; + + public function __construct(Dataset $dataset) + { + $this->dataset = $dataset; + } + + public function getId() + { + return $this->id; + } + + public function getLabel() + { + return $this->label; + } + + public function setLabel($label) + { + $this->label = $label; + } + + public function getFilePath() + { + return $this->filePath; + } + + public function setFilePath($filePath) + { + $this->filePath = $filePath; + } + + public function getFileSize() + { + return $this->fileSize; + } + + public function setFileSize($fileSize) + { + $this->fileSize = $fileSize; + } + + public function getType() + { + return $this->type; + } + + public function setType($type) + { + $this->type = $type; + } + + public function jsonSerialize(): array + { + return [ + 'id' => $this->getId(), + 'label' => $this->getLabel(), + 'file_path' => $this->getFilePath(), + 'file_size' => $this->getFileSize(), + 'type' => $this->getType() + ]; + } +}