diff --git a/client/src/app/admin/instance/containers/configure-instance.component.ts b/client/src/app/admin/instance/containers/configure-instance.component.ts index ace89da19d750cce42ac7d6458b2dd269ce02335..d078026e37d79cc888794dd576fc5b6ecfaf0302 100644 --- a/client/src/app/admin/instance/containers/configure-instance.component.ts +++ b/client/src/app/admin/instance/containers/configure-instance.component.ts @@ -13,6 +13,7 @@ import { Store } from '@ngrx/store'; import * as datasetFamilyActions from 'src/app/metamodel/actions/dataset-family.actions'; import * as datasetActions from 'src/app/metamodel/actions/dataset.actions'; import * as datasetGroupActions from 'src/app/metamodel/actions/dataset-group.actions'; +import * as webpageActions from 'src/app/metamodel/actions/webpage.actions'; @Component({ selector: 'app-configure-instance', @@ -25,5 +26,6 @@ export class ConfigureInstanceComponent implements OnInit { Promise.resolve(null).then(() => this.store.dispatch(datasetFamilyActions.loadDatasetFamilyList())); Promise.resolve(null).then(() => this.store.dispatch(datasetActions.loadDatasetList())); Promise.resolve(null).then(() => this.store.dispatch(datasetGroupActions.loadDatasetGroupList())); + Promise.resolve(null).then(() => this.store.dispatch(webpageActions.loadWebpageList())); } } diff --git a/client/src/app/admin/instance/webpages/components/index.ts b/client/src/app/admin/instance/webpages/components/index.ts index 6de339c71a1248ffa8b768bba4995eab4b3ff143..b624023a22f0ee170d8013948fc87809ff2de4aa 100644 --- a/client/src/app/admin/instance/webpages/components/index.ts +++ b/client/src/app/admin/instance/webpages/components/index.ts @@ -7,5 +7,10 @@ * file that was distributed with this source code. */ +import { WebpageListMenuComponent } from './webpage-list-menu.component'; +import { WebpageContentComponent } from './webpage-content.component'; + export const dummiesComponents = [ + WebpageListMenuComponent, + WebpageContentComponent ]; diff --git a/client/src/app/admin/instance/webpages/components/webpage-content.component.html b/client/src/app/admin/instance/webpages/components/webpage-content.component.html new file mode 100644 index 0000000000000000000000000000000000000000..a7dd4a56980a6549923008e7068272a7c423b1d1 --- /dev/null +++ b/client/src/app/admin/instance/webpages/components/webpage-content.component.html @@ -0,0 +1,24 @@ +<form [formGroup]="form" (ngSubmit)="savePage()" novalidate> + <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="display">Display</label> + <input type="number" class="form-control" id="display" name="display" formControlName="display"> + </div> + <div class="form-group"> + <label for="title">Title</label> + <input type="text" class="form-control" id="title" name="title" formControlName="title"> + </div> + <div class="form-group"> + <label for="content">Content</label> + <editor formControlName="content" [init]="{ plugins: 'lists link image table code help wordcount' }"> + </editor> + </div> + <div class="form-group"> + <button [disabled]="!form.valid" type="submit" class="btn btn-primary"> + <span class="fa fa-database"></span> Save + </button> + </div> +</form> diff --git a/client/src/app/admin/instance/webpages/components/webpage-content.component.ts b/client/src/app/admin/instance/webpages/components/webpage-content.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..346301666ee582228403147adb4b6535eead4a4d --- /dev/null +++ b/client/src/app/admin/instance/webpages/components/webpage-content.component.ts @@ -0,0 +1,28 @@ +import { Component, Input } from '@angular/core'; +import { FormGroup, FormControl, Validators } from '@angular/forms'; + +import { Webpage } from 'src/app/metamodel/models'; + +@Component({ + selector: 'app-webpage-content', + templateUrl: 'webpage-content.component.html' +}) +export class WebpageContentComponent { + @Input() webPage: Webpage; + + public form = new FormGroup({ + label: new FormControl('', [Validators.required]), + display: new FormControl('', [Validators.required]), + title: new FormControl('', [Validators.required]), + content: new FormControl('', Validators.required) + }); + + ngOnInit() { + this.form.patchValue(this.webPage); + this.form.controls.content.markAsPristine(); + } + + savePage() { + console.log(this.form.value); + } +} \ No newline at end of file diff --git a/client/src/app/admin/instance/webpages/components/webpage-list-menu.component.html b/client/src/app/admin/instance/webpages/components/webpage-list-menu.component.html new file mode 100644 index 0000000000000000000000000000000000000000..e9a722017b716234b44c163e454177810037a8b6 --- /dev/null +++ b/client/src/app/admin/instance/webpages/components/webpage-list-menu.component.html @@ -0,0 +1,39 @@ +<nav> + <div class="nav flex-column nav-pills" id="v-pills-tab" role="tablist" aria-orientation="vertical"> + <a *ngFor="let webpage of webpageList" routerLink="./" [queryParams]="{webpage_selected: webpage.id}" class="nav-link" [ngClass]="{'active': isActive(webpage)}" data-toggle="pill" role="tab"> + {{ webpage.label }} + </a> + <a (click)="openModal(template)" class="nav-link text-center pointer" title="Add a new webpage" data-toggle="pill" role="tab"> + <span class="fas fa-plus"></span> + </a> + </div> +</nav> + +<ng-template #template> + <div class="modal-header"> + <h4 class="modal-title pull-left"><strong>Add a new webpage</strong></h4> + </div> + <div class="modal-body"> + <form [formGroup]="form" (ngSubmit)="submit()" novalidate> + <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="display">Display</label> + <input type="number" class="form-control" id="display" name="display" formControlName="display"> + </div> + <div class="form-group"> + <label for="title">Title</label> + <input type="text" class="form-control" id="title" name="title" formControlName="title"> + </div> + <div class="form-group"> + <button [disabled]="!form.valid || form.pristine" type="submit" class="btn btn-primary"> + <span class="fa fa-database"></span> Add the webpage + </button> + + <a (click)="modalRef.hide()" type="button" class="btn btn-danger">Cancel</a> + </div> + </form> + </div> +</ng-template> diff --git a/client/src/app/admin/instance/webpages/components/webpage-list-menu.component.ts b/client/src/app/admin/instance/webpages/components/webpage-list-menu.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..94abf57aff792e3f72a4c0cad3ca5dab7f9e6c23 --- /dev/null +++ b/client/src/app/admin/instance/webpages/components/webpage-list-menu.component.ts @@ -0,0 +1,47 @@ +import { Component, ChangeDetectionStrategy, Input, Output, EventEmitter, TemplateRef } from '@angular/core'; +import { FormGroup, FormControl, Validators } from '@angular/forms'; + +import { BsModalService } from 'ngx-bootstrap/modal'; +import { BsModalRef } from 'ngx-bootstrap/modal/bs-modal-ref.service'; + +import { Webpage } from 'src/app/metamodel/models'; + +@Component({ + selector: 'app-webpage-list-menu', + templateUrl: 'webpage-list-menu.component.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class WebpageListMenuComponent { + @Input() webpageList: Webpage[]; + @Input() webpageSelected: Webpage; + @Output() addWebpage: EventEmitter<Webpage> = new EventEmitter(); + + modalRef: BsModalRef; + public form = new FormGroup({ + label: new FormControl('', [Validators.required]), + display: new FormControl('', [Validators.required]), + title: new FormControl('', [Validators.required]), + content: new FormControl('Add your text here...', [Validators.required]) + }); + + constructor(private modalService: BsModalService) { } + + isActive(webpage: Webpage) { + let active = false; + if (this.webpageSelected && webpage.id === this.webpageSelected.id) { + active = true; + } + return active; + } + + openModal(template: TemplateRef<any>) { + this.modalRef = this.modalService.show(template); + } + + submit() { + this.addWebpage.emit(this.form.value); + this.form.reset(); + this.form.controls.content.setValue('Add your text here...'); + this.modalRef.hide(); + } +} diff --git a/client/src/app/admin/instance/webpages/containers/webpages-list.component.html b/client/src/app/admin/instance/webpages/containers/webpages-list.component.html index ca6f8b0ad94bf928b2f07348a98896a6080c1625..174c553c5c6cd7c86c41e63f184dd802b84c0c75 100644 --- a/client/src/app/admin/instance/webpages/containers/webpages-list.component.html +++ b/client/src/app/admin/instance/webpages/containers/webpages-list.component.html @@ -15,16 +15,18 @@ </ol> </nav> - <div class="row"> - <nav class="col-md-2 col-sm-12 mb-2"> - <div class="nav flex-column nav-pills" id="v-pills-tab" role="tablist" aria-orientation="vertical"> - <a class="nav-link active" id="v-pills-home-tab" data-toggle="pill" href="#v-pills-home" role="tab" aria-controls="v-pills-home" aria-selected="true">Home</a> - </div> - </nav> - <div class="col-md-10 col-sm-12"> - <editor [init]="{ plugins: 'lists link image table code help wordcount' }" [(ngModel)]="dataModel"> - </editor> - <button (click)="savePage()" class="btn btn-outline-primary mt-2">Save</button> + <app-spinner *ngIf="webpageListIsLoading | async"></app-spinner> + + <div *ngIf="webpageListIsLoaded | async" class="row"> + <div class="col-md-2 col-sm-12 mb-2"> + <app-webpage-list-menu + [webpageList]="webpageList | async" + [webpageSelected]="webpageSelected | async" + (addWebpage)="addWebpage($event)"> + </app-webpage-list-menu> + </div> + <div *ngIf="webpageSelected | async" class="col-md-10 col-sm-12"> + <app-webpage-content [webPage]="webpageSelected | async"></app-webpage-content> </div> </div> </div> diff --git a/client/src/app/admin/instance/webpages/containers/webpages-list.component.scss b/client/src/app/admin/instance/webpages/containers/webpages-list.component.scss deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/client/src/app/admin/instance/webpages/containers/webpages-list.component.ts b/client/src/app/admin/instance/webpages/containers/webpages-list.component.ts index 6f5f42621702f768fc92c5e60eb35e2221f5fe8c..102e62b61618a00615572e1bb35e6117870e1078 100644 --- a/client/src/app/admin/instance/webpages/containers/webpages-list.component.ts +++ b/client/src/app/admin/instance/webpages/containers/webpages-list.component.ts @@ -8,26 +8,43 @@ */ import { Component } from '@angular/core'; + import { Observable } from 'rxjs'; import { Store } from '@ngrx/store'; +import { Webpage } from 'src/app/metamodel/models'; import * as instanceSelector from 'src/app/metamodel/selectors/instance.selector'; +import * as webpageActions from 'src/app/metamodel/actions/webpage.actions'; +import * as webpageSelector from 'src/app/metamodel/selectors/webpage.selector'; @Component({ selector: 'app-webpages-list', - templateUrl: 'webpages-list.component.html', - styleUrls: [ 'webpages-list.component.scss' ] + templateUrl: 'webpages-list.component.html' }) export class WebpagesListComponent { public instanceName: Observable<string>; - - dataModel: string; + public webpageListIsLoading: Observable<boolean>; + public webpageListIsLoaded: Observable<boolean>; + public webpageList: Observable<Webpage[]>; + public webpageSelected: Observable<Webpage>; constructor(private store: Store<{ }>) { + this.webpageSelected = store.select(webpageSelector.selectWebpageByQueryParamId); this.instanceName = this.store.select(instanceSelector.selectInstanceNameByRoute); + this.webpageListIsLoading = store.select(webpageSelector.selectWebpageListIsLoading); + this.webpageListIsLoaded = store.select(webpageSelector.selectWebpageListIsLoaded); + this.webpageList = store.select(webpageSelector.selectAllWebpages); + } + + addWebpage(webpage: Webpage) { + this.store.dispatch(webpageActions.addWebpage({ webpage })); + } + + editWebpage(webpage: Webpage) { + this.store.dispatch(webpageActions.editWebpage({ webpage })); } - savePage() { - console.log(this.dataModel); + deleteWebpage(webpage: Webpage) { + this.store.dispatch(webpageActions.deleteWebpage({ webpage })); } } diff --git a/client/src/app/metamodel/actions/webpage.actions.ts b/client/src/app/metamodel/actions/webpage.actions.ts new file mode 100644 index 0000000000000000000000000000000000000000..9e068d01b1e625fb99ae8838d564d75aeabed95e --- /dev/null +++ b/client/src/app/metamodel/actions/webpage.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 { Webpage } from '../models'; + +export const loadWebpageList = createAction('[Metamodel] Load Webpage List'); +export const loadWebpageListSuccess = createAction('[Metamodel] Load Webpage List Success', props<{ webpages: Webpage[] }>()); +export const loadWebpageListFail = createAction('[Metamodel] Load Webpage List Fail'); +export const addWebpage = createAction('[Metamodel] Add Webpage', props<{ webpage: Webpage }>()); +export const addWebpageSuccess = createAction('[Metamodel] Add Webpage Success', props<{ webpage: Webpage }>()); +export const addWebpageFail = createAction('[Metamodel] Add Webpage Fail'); +export const editWebpage = createAction('[Metamodel] Edit Webpage', props<{ webpage: Webpage }>()); +export const editWebpageSuccess = createAction('[Metamodel] Edit Webpage Success', props<{ webpage: Webpage }>()); +export const editWebpageFail = createAction('[Metamodel] Edit Webpage Fail'); +export const deleteWebpage = createAction('[Metamodel] Delete Webpage', props<{ webpage: Webpage }>()); +export const deleteWebpageSuccess = createAction('[Metamodel] Delete Webpage Success', props<{ webpage: Webpage }>()); +export const deleteWebpageFail = createAction('[Metamodel] Delete Webpage Fail'); diff --git a/client/src/app/metamodel/effects/index.ts b/client/src/app/metamodel/effects/index.ts index 111bd9f732e401b4dc337afedbc0a66adfb68728..536caf45ac449690a9a5df685b522b1739365c99 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 { ImageEffects } from './image.effects'; import { FileEffects } from './file.effects'; import { ConeSearchConfigEffects } from './cone-search-config.effects' +import { WebpageEffects } from './webpage.effects'; export const metamodelEffects = [ DatabaseEffects, @@ -34,5 +35,6 @@ export const metamodelEffects = [ OutputFamilyEffects, ImageEffects, FileEffects, - ConeSearchConfigEffects + ConeSearchConfigEffects, + WebpageEffects ]; diff --git a/client/src/app/metamodel/effects/webpage.effects.ts b/client/src/app/metamodel/effects/webpage.effects.ts new file mode 100644 index 0000000000000000000000000000000000000000..8294a5072ff963ba2af84e0327d63de8f8c386ac --- /dev/null +++ b/client/src/app/metamodel/effects/webpage.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 webpageActions from '../actions/webpage.actions'; +import { WebpageService } from '../services/webpage.service'; +import * as instanceSelector from '../selectors/instance.selector'; + +/** + * @class + * @classdesc Webpage effects. + */ +@Injectable() +export class WebpageEffects { + /** + * Calls action to retrieve webpage list. + */ + loadWebpages$ = createEffect((): any => + this.actions$.pipe( + ofType(webpageActions.loadWebpageList), + concatLatestFrom(() => this.store.select(instanceSelector.selectInstanceNameByRoute)), + mergeMap(([, instanceName]) => this.webpageService.retrieveWebpageList(instanceName) + .pipe( + map(webpages => webpageActions.loadWebpageListSuccess({ webpages })), + catchError(() => of(webpageActions.loadWebpageListFail())) + ) + ) + ) + ); + + /** + * Calls action to add a webpage. + */ + addWebpage$ = createEffect((): any => + this.actions$.pipe( + ofType(webpageActions.addWebpage), + concatLatestFrom(() => this.store.select(instanceSelector.selectInstanceNameByRoute)), + mergeMap(([action, instanceName]) => this.webpageService.addWebpage(instanceName, action.webpage) + .pipe( + map(webpage => webpageActions.addWebpageSuccess({ webpage })), + catchError(() => of(webpageActions.addWebpageFail())) + ) + ) + ) + ); + + /** + * Displays add webpage success notification. + */ + addWebpageSuccess$ = createEffect(() => + this.actions$.pipe( + ofType(webpageActions.addWebpageSuccess), + tap(() => this.toastr.success('Webpage successfully added', 'The new webpage was added into the database')) + ), { dispatch: false } + ); + + /** + * Displays add webpage fail notification. + */ + addWebpageFail$ = createEffect(() => + this.actions$.pipe( + ofType(webpageActions.addWebpageFail), + tap(() => this.toastr.error('Failure to add webpage', 'The new webpage could not be added into the database')) + ), { dispatch: false } + ); + + /** + * Calls action to modify a webpage. + */ + editWebpage$ = createEffect((): any => + this.actions$.pipe( + ofType(webpageActions.editWebpage), + mergeMap(action => this.webpageService.editWebpage(action.webpage) + .pipe( + map(webpage => webpageActions.editWebpageSuccess({ webpage })), + catchError(() => of(webpageActions.editWebpageFail())) + ) + ) + ) + ); + + /** + * Displays edit webpage success notification. + */ + editWebpageSuccess$ = createEffect(() => + this.actions$.pipe( + ofType(webpageActions.editWebpageSuccess), + tap(() => this.toastr.success('Webpage successfully edited', 'The existing webpage has been edited into the database')) + ), { dispatch: false } + ); + + /** + * Displays edit webpage error notification. + */ + editWebpageFail$ = createEffect(() => + this.actions$.pipe( + ofType(webpageActions.editWebpageFail), + tap(() => this.toastr.error('Failure to edit webpage', 'The existing webpage could not be edited into the database')) + ), { dispatch: false } + ); + + /** + * Calls action to remove a webpage. + */ + deleteWebpage$ = createEffect((): any => + this.actions$.pipe( + ofType(webpageActions.deleteWebpage), + mergeMap(action => this.webpageService.deleteWebpage(action.webpage.id) + .pipe( + map(() => webpageActions.deleteWebpageSuccess({ webpage: action.webpage })), + catchError(() => of(webpageActions.deleteWebpageFail())) + ) + ) + ) + ); + + /** + * Displays delete webpage success notification. + */ + deleteWebpageSuccess$ = createEffect(() => + this.actions$.pipe( + ofType(webpageActions.deleteWebpageSuccess), + tap(() => this.toastr.success('Webpage successfully deleted', 'The existing webpage has been deleted')) + ), { dispatch: false } + ); + + /** + * Displays delete webpage error notification. + */ + deleteWebpageFail$ = createEffect(() => + this.actions$.pipe( + ofType(webpageActions.deleteWebpageFail), + tap(() => this.toastr.error('Failure to delete webpage', 'The existing webpage could not be deleted from the database')) + ), { dispatch: false } + ); + + constructor( + private actions$: Actions, + private webpageService: WebpageService, + private toastr: ToastrService, + private store: Store<{ }> + ) {} +} diff --git a/client/src/app/metamodel/metamodel.reducer.ts b/client/src/app/metamodel/metamodel.reducer.ts index 2cccdfc3787cfaac8e61e759cd38e77e93f7daa0..1549262bba3c7ef0616a66a30fbbf00ca0e2bfa8 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 image from './reducers/image.reducer'; import * as file from './reducers/file.reducer'; import * as coneSearchConfig from './reducers/cone-search-config.reducer'; +import * as webpage from './reducers/webpage.reducer'; /** * Interface for metamodel state. @@ -43,6 +44,7 @@ export interface State { image: image.State; file: file.State; coneSearchConfig: coneSearchConfig.State; + webpage: webpage.State; } const reducers = { @@ -58,7 +60,8 @@ const reducers = { outputFamily: outputFamily.outputFamilyReducer, image: image.imageReducer, file: file.fileReducer, - coneSearchConfig: coneSearchConfig.coneSearchConfigReducer + coneSearchConfig: coneSearchConfig.coneSearchConfigReducer, + webpage: webpage.webpageReducer }; export const metamodelReducer = combineReducers(reducers); diff --git a/client/src/app/metamodel/models/index.ts b/client/src/app/metamodel/models/index.ts index 82f01695472cea1a22424bf1a7465fdcd6f168b5..aea0035164ddbe4aef7ca26ea60985496c9c7d73 100644 --- a/client/src/app/metamodel/models/index.ts +++ b/client/src/app/metamodel/models/index.ts @@ -22,4 +22,5 @@ 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 +export * from './file.model'; +export * from './webpage.model'; diff --git a/client/src/app/metamodel/models/webpage.model.ts b/client/src/app/metamodel/models/webpage.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..b7a02447bd040df9a9f70c991739c2599390e1b3 --- /dev/null +++ b/client/src/app/metamodel/models/webpage.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 web page. + * + * @interface Webpage + */ +export interface Webpage { + id: number; + label: string; + display: number; + title: string; + content: string; +} diff --git a/client/src/app/metamodel/reducers/webpage.reducer.ts b/client/src/app/metamodel/reducers/webpage.reducer.ts new file mode 100644 index 0000000000000000000000000000000000000000..cd0a982086bef28ee5cfd7134ff482f5666cd4c2 --- /dev/null +++ b/client/src/app/metamodel/reducers/webpage.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 { Webpage } from '../models'; +import * as webpageActions from '../actions/webpage.actions'; + +/** + * Interface for webpage state. + * + * @interface State + */ +export interface State extends EntityState<Webpage> { + webpageListIsLoading: boolean; + webpageListIsLoaded: boolean; +} + +export const adapter: EntityAdapter<Webpage> = createEntityAdapter<Webpage>({ + selectId: (webpage: Webpage) => webpage.id, + sortComparer: (a: Webpage, b: Webpage) => a.display - b.display +}); + +export const initialState: State = adapter.getInitialState({ + webpageListIsLoading: false, + webpageListIsLoaded: false +}); + +export const webpageReducer = createReducer( + initialState, + on(webpageActions.loadWebpageList, (state) => { + return { + ...state, + webpageListIsLoading: true + } + }), + on(webpageActions.loadWebpageListSuccess, (state, { webpages }) => { + return adapter.setAll( + webpages, + { + ...state, + webpageListIsLoading: false, + webpageListIsLoaded: true + } + ); + }), + on(webpageActions.loadWebpageListFail, (state) => { + return { + ...state, + webpageListIsLoading: false + } + }), + on(webpageActions.addWebpageSuccess, (state, { webpage }) => { + return adapter.addOne(webpage, state) + }), + on(webpageActions.editWebpageSuccess, (state, { webpage }) => { + return adapter.setOne(webpage, state) + }), + on(webpageActions.deleteWebpageSuccess, (state, { webpage }) => { + return adapter.removeOne(webpage.id, state) + }) +); + +const { + selectIds, + selectEntities, + selectAll, + selectTotal, +} = adapter.getSelectors(); + +export const selectWebpageIds = selectIds; +export const selectWebpageEntities = selectEntities; +export const selectAllWebpages = selectAll; +export const selectWebpageTotal = selectTotal; + +export const selectWebpageListIsLoading = (state: State) => state.webpageListIsLoading; +export const selectWebpageListIsLoaded = (state: State) => state.webpageListIsLoaded; diff --git a/client/src/app/metamodel/selectors/webpage.selector.ts b/client/src/app/metamodel/selectors/webpage.selector.ts new file mode 100644 index 0000000000000000000000000000000000000000..1ca79a3d83fdd8f35058b53c52d084789090b104 --- /dev/null +++ b/client/src/app/metamodel/selectors/webpage.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 fromWebpage from '../reducers/webpage.reducer'; + +export const selectWebpageState = createSelector( + reducer.getMetamodelState, + (state: reducer.State) => state.webpage +); + +export const selectWebpageIds = createSelector( + selectWebpageState, + fromWebpage.selectWebpageIds +); + +export const selectWebpageEntities = createSelector( + selectWebpageState, + fromWebpage.selectWebpageEntities +); + +export const selectAllWebpages = createSelector( + selectWebpageState, + fromWebpage.selectAllWebpages +); + +export const selectWebpageTotal = createSelector( + selectWebpageState, + fromWebpage.selectWebpageTotal +); + +export const selectWebpageListIsLoading = createSelector( + selectWebpageState, + fromWebpage.selectWebpageListIsLoading +); + +export const selectWebpageListIsLoaded = createSelector( + selectWebpageState, + fromWebpage.selectWebpageListIsLoaded +); + +export const selectWebpageByQueryParamId = createSelector( + selectWebpageEntities, + reducer.selectRouterState, + (entities, router) => entities[router.state.queryParams.webpage_selected] +); diff --git a/client/src/app/metamodel/services/index.ts b/client/src/app/metamodel/services/index.ts index c24336c01c0642c22818a75ee76c8be5670a9f0e..3812732f206f17b3323eb8ca8a64e3f402e3acdc 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 { ImageService } from './image.service'; import { FileService } from './file.service'; import { ConeSearchConfigService } from './cone-search-config.service'; +import { WebpageService } from './webpage.service'; export const metamodelServices = [ DatabaseService, @@ -34,5 +35,6 @@ export const metamodelServices = [ OutputFamilyService, ImageService, FileService, - ConeSearchConfigService + ConeSearchConfigService, + WebpageService ]; diff --git a/client/src/app/metamodel/services/webpage.service.ts b/client/src/app/metamodel/services/webpage.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..d19d75ea6d909db1d976cf983b9b803e200e7b09 --- /dev/null +++ b/client/src/app/metamodel/services/webpage.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 { Webpage } from '../models'; +import { AppConfigService } from 'src/app/app-config.service'; + +/** + * @class + * @classdesc Webpage service. + */ +@Injectable() +export class WebpageService { + constructor(private http: HttpClient, private config: AppConfigService) { } + + /** + * Retrieves webpages for the given instance. + * + * @param {string} instanceName - The instance. + * + * @return Observable<Webpage[]> + */ + retrieveWebpageList(instanceName: string): Observable<Webpage[]> { + return this.http.get<Webpage[]>(`${this.config.apiUrl}/instance/${instanceName}/webpage`); + } + + /** + * Adds a new webpage for the given instance. + * + * @param {string} instanceName - The instance. + * @param {Webpage} newWebpage - The webpage. + * + * @return Observable<Webpage> + */ + addWebpage(instanceName: string, newWebpage: Webpage): Observable<Webpage> { + return this.http.post<Webpage>(`${this.config.apiUrl}/instance/${instanceName}/webpage`, newWebpage); + } + + /** + * Modifies a webpage. + * + * @param {Webpage} webpage - The Webpage. + * + * @return Observable<Webpage> + */ + editWebpage(webpage: Webpage): Observable<Webpage> { + return this.http.put<Webpage>(`${this.config.apiUrl}/webpage/${webpage.id}`, webpage); + } + + /** + * Removes a webpage. + * + * @param {number} webpageId - The webpage ID. + * + * @return Observable<object> + */ + deleteWebpage(webpageId: number): Observable<object> { + return this.http.delete(`${this.config.apiUrl}/webpage/${webpageId}`); + } +} diff --git a/client/src/styles.scss b/client/src/styles.scss index 2f276ed780c804c7a33845a14cafaff9d99f1680..d706a62faffc0855002d27a111992b3351d8ab67 100644 --- a/client/src/styles.scss +++ b/client/src/styles.scss @@ -79,11 +79,11 @@ main { } /* Angular forms */ -input.ng-valid, select.ng-valid, .ng-select.ng-valid div.ng-select-container, textarea.ng-valid { +input.ng-valid, select.ng-valid, .ng-select.ng-valid div.ng-select-container, textarea.ng-valid, editor.ng-valid { border-left: 5px solid #42A948; /* green */ } -input.ng-invalid, select.ng-invalid, .ng-select.ng-invalid div.ng-select-container, textarea.ng-invalid { +input.ng-invalid, select.ng-invalid, .ng-select.ng-invalid div.ng-select-container, textarea.ng-invalid, editor.ng-invalid { border-left: 5px solid #a94442; /* red */ } diff --git a/server/app/dependencies.php b/server/app/dependencies.php index 994388f37e4bc123ab59b62f8994d076a30dc551..583295365248b049cad9b0980ec3edf99cf5dcf6 100644 --- a/server/app/dependencies.php +++ b/server/app/dependencies.php @@ -145,6 +145,14 @@ $container->set('App\Action\DatasetAction', function (ContainerInterface $c) { return new App\Action\DatasetAction($c->get('em')); }); +$container->set('App\Action\WebpageListAction', function (ContainerInterface $c) { + return new App\Action\WebpageListAction($c->get('em')); +}); + +$container->set('App\Action\WebpageAction', function (ContainerInterface $c) { + return new App\Action\WebpageAction($c->get('em')); +}); + $container->set('App\Action\CriteriaFamilyListAction', function (ContainerInterface $c) { return new App\Action\CriteriaFamilyListAction($c->get('em')); }); diff --git a/server/app/routes.php b/server/app/routes.php index 2b6715908160e51c1ca4dfbd9c8d8e8ac0b44464..10993e68633d8a2671b146cc54c1c7f9f1b32e0f 100644 --- a/server/app/routes.php +++ b/server/app/routes.php @@ -40,9 +40,11 @@ $app->group('', function (RouteCollectorProxy $group) { $group->map([OPTIONS, GET], '/instance/{name}/file-explorer[{fpath:.*}]', App\Action\InstanceFileExplorerAction::class); $group->map([OPTIONS, GET, POST], '/instance/{name}/dataset-group', App\Action\DatasetGroupListAction::class); $group->map([OPTIONS, GET, POST], '/instance/{name}/dataset-family', App\Action\DatasetFamilyListAction::class); + $group->map([OPTIONS, GET, POST], '/instance/{name}/webpage', App\Action\WebpageListAction::class); $group->map([OPTIONS, GET], '/instance/{name}/dataset', App\Action\DatasetListByInstanceAction::class); $group->map([OPTIONS, GET, PUT, DELETE], '/dataset-group/{id}', App\Action\DatasetGroupAction::class); $group->map([OPTIONS, GET, PUT, DELETE], '/dataset-family/{id}', App\Action\DatasetFamilyAction::class); + $group->map([OPTIONS, GET, PUT, DELETE], '/webpage/{id}', App\Action\WebpageAction::class); $group->map([OPTIONS, GET, POST], '/dataset-family/{id}/dataset', App\Action\DatasetListAction::class); $group->map([OPTIONS, GET, PUT, DELETE], '/dataset/{name}', App\Action\DatasetAction::class); $group->map([OPTIONS, GET], '/dataset/{name}/file-explorer[{fpath:.*}]', App\Action\DatasetFileExplorerAction::class); diff --git a/server/src/Action/WebpageAction.php b/server/src/Action/WebpageAction.php new file mode 100644 index 0000000000000000000000000000000000000000..28ea184c4705b361ff66dacc072b80ff29e35468 --- /dev/null +++ b/server/src/Action/WebpageAction.php @@ -0,0 +1,106 @@ +<?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\Webpage; + +/** + * @author François Agneray <francois.agneray@lam.fr> + * @package App\Action + */ +final class WebpageAction extends AbstractAction +{ + /** + * `GET` Returns the webpage found + * `PUT` Full update the webpage and returns the new version + * `DELETE` Delete the webpage 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 webpage with primary key + $webpage = $this->em->find('App\Entity\Webpage', $args['id']); + + // If webpage is not found 404 + if (is_null($webpage)) { + throw new HttpNotFoundException( + $request, + 'Webpage with id ' . $args['id'] . ' is not found' + ); + } + + if ($request->getMethod() === GET) { + $payload = json_encode($webpage); + } + + if ($request->getMethod() === PUT) { + $parsedBody = $request->getParsedBody(); + + $fields = array('label', 'display', 'title', 'content'); + foreach ($fields as $a) { + if (!array_key_exists($a, $parsedBody)) { + throw new HttpBadRequestException( + $request, + 'Param ' . $a . ' needed to edit the webpage' + ); + } + } + + $this->editWebpage($webpage, $parsedBody); + $payload = json_encode($webpage); + } + + if ($request->getMethod() === DELETE) { + $id = $webpage->getId(); + $this->em->remove($webpage); + $this->em->flush(); + $payload = json_encode(array( + 'message' => 'Webpage with id ' . $id . ' is removed!' + )); + } + + $response->getBody()->write($payload); + return $response; + } + + /** + * Update webpage object with setters + * + * @param Webpage $webpage The webpage to update + * @param array $parsedBody Contains the new values ​​of the webpage sent by the user + */ + private function editWebpage(Webpage $webpage, array $parsedBody): void + { + $webpage->setLabel($parsedBody['label']); + $webpage->setDisplay($parsedBody['display']); + $webpage->setTitle($parsedBody['title']); + $webpage->setContent($parsedBody['content']); + $this->em->flush(); + } +} diff --git a/server/src/Action/WebpageListAction.php b/server/src/Action/WebpageListAction.php new file mode 100644 index 0000000000000000000000000000000000000000..4d2bca03cd8e138aeba76e997588724da990e9a7 --- /dev/null +++ b/server/src/Action/WebpageListAction.php @@ -0,0 +1,105 @@ +<?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\Instance; +use App\Entity\Webpage; + +/** + * @author François Agneray <francois.agneray@lam.fr> + * @package App\Action + */ +final class WebpageListAction extends AbstractAction +{ + /** + * `GET` Returns a list of all webpages for a given instance + * `POST` Add a new webpage to a given instance + * + * @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'); + } + + $instance = $this->em->find('App\Entity\Instance', $args['name']); + + // Returns HTTP 404 if the instance is not found + if (is_null($instance)) { + throw new HttpNotFoundException( + $request, + 'Instance with name ' . $args['name'] . ' is not found' + ); + } + + if ($request->getMethod() === GET) { + $webpages = $this->em->getRepository('App\Entity\Webpage')->findBy(array('instance' => $instance)); + $payload = json_encode($webpages); + } + + if ($request->getMethod() === POST) { + $parsedBody = $request->getParsedBody(); + + // To work this action needs information + foreach (array('label', 'display', 'title', 'content') as $a) { + if (!array_key_exists($a, $parsedBody)) { + throw new HttpBadRequestException( + $request, + 'Param ' . $a . ' needed to add a new webpage' + ); + } + } + + $webpage = $this->postWebpage($parsedBody, $instance); + $payload = json_encode($webpage); + $response = $response->withStatus(201); + } + + $response->getBody()->write($payload); + return $response; + } + + /** + * Add a new webpage into the metamodel + * + * @param array $parsedBody Contains the values ​​of the new webpage sent by the user + * @param Instance $instance The instance for adding the webpage + * + * @return Webpage + */ + private function postWebpage(array $parsedBody, Instance $instance): Webpage + { + $webpage = new Webpage($instance); + $webpage->setLabel($parsedBody['label']); + $webpage->setDisplay($parsedBody['display']); + $webpage->setTitle($parsedBody['title']); + $webpage->setContent($parsedBody['content']); + + $this->em->persist($webpage); + $this->em->flush(); + + return $webpage; + } +} diff --git a/server/src/Entity/Webpage.php b/server/src/Entity/Webpage.php new file mode 100644 index 0000000000000000000000000000000000000000..026988edc54bf4b58eb64deeed5deac74ad66bed --- /dev/null +++ b/server/src/Entity/Webpage.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="webpage") + */ +class Webpage 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 int + * + * @Column(type="integer", name="display", nullable=false) + */ + protected $display; + + /** + * @var string + * + * @Column(type="string", name="title", nullable=false) + */ + protected $title; + + /** + * @var string + * + * @Column(type="string", name="content", nullable=false) + */ + protected $content; + + /** + * @var Instance + * + * @ManyToOne(targetEntity="Instance", inversedBy="datasetFamilies") + * @JoinColumn(name="instance_name", referencedColumnName="name", nullable=false, onDelete="CASCADE") + */ + protected $instance; + + public function __construct(Instance $instance) + { + $this->instance = $instance; + } + + public function getId() + { + return $this->id; + } + + public function getLabel() + { + return $this->label; + } + + public function setLabel($label) + { + $this->label = $label; + } + + public function getDisplay() + { + return $this->display; + } + + public function setDisplay($display) + { + $this->display = $display; + } + + public function getTitle() + { + return $this->title; + } + + public function setTitle($title) + { + $this->title = $title; + } + + public function getContent() + { + return $this->content; + } + + public function setContent($content) + { + $this->content = $content; + } + + public function jsonSerialize(): array + { + return [ + 'id' => $this->getId(), + 'label' => $this->getLabel(), + 'display' => $this->getDisplay(), + 'title' => $this->getTitle(), + 'content'=> $this->getContent() + ]; + } +}