From d272059a7bf2fd2f01fbf9ee82c16cb59468ef63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Agneray?= <francois.agneray@lam.fr> Date: Tue, 2 Nov 2021 21:11:29 +0100 Subject: [PATCH] Search type SVOM Json Keywords => done --- client/src/app/instance/instance.reducer.ts | 7 +- .../criteria-by-family.component.html | 3 + .../criteria-by-family.component.spec.ts | 14 ++- .../criteria/criteria-by-family.component.ts | 5 +- .../criteria/criteria-tabs.component.html | 3 + .../criteria/criteria-tabs.component.spec.ts | 3 +- .../criteria/criteria-tabs.component.ts | 5 +- .../criteria/search-type/json.component.html | 15 ++- .../search-type/json.component.spec.ts | 5 +- .../search-type/svom-json-kw.component.html | 28 +++--- .../svom-json-kw.component.spec.ts | 93 +++++++++++++++++++ .../search-type/svom-json-kw.component.ts | 52 ++++------- .../search/containers/criteria.component.html | 3 + .../containers/criteria.component.spec.ts | 3 +- .../search/containers/criteria.component.ts | 14 ++- .../store/actions/svom-json-kw.actions.ts | 18 ++++ .../src/app/instance/store/effects/index.ts | 4 +- .../effects/svom-json-kw.effects.spec.ts | 44 +++++++++ .../store/effects/svom-json-kw.effects.ts | 71 ++++++++++++++ client/src/app/instance/store/models/index.ts | 1 + .../store/models/svom-keyword.model.ts | 6 ++ .../reducers/svom-json-kw.reducer.spec.ts | 82 ++++++++++++++++ .../store/reducers/svom-json-kw.reducer.ts | 64 +++++++++++++ .../selectors/svom-json-kw.selector.spec.ts | 24 +++++ .../store/selectors/svom-json-kw.selector.ts | 38 ++++++++ .../src/app/instance/store/services/index.ts | 4 +- .../services/svom-json-kw.service.spec.ts | 48 ++++++++++ .../store/services/svom-json-kw.service.ts | 30 ++++++ client/src/styles.scss | 8 ++ 29 files changed, 625 insertions(+), 70 deletions(-) create mode 100644 client/src/app/instance/search/components/criteria/search-type/svom-json-kw.component.spec.ts create mode 100644 client/src/app/instance/store/actions/svom-json-kw.actions.ts create mode 100644 client/src/app/instance/store/effects/svom-json-kw.effects.spec.ts create mode 100644 client/src/app/instance/store/effects/svom-json-kw.effects.ts create mode 100644 client/src/app/instance/store/models/svom-keyword.model.ts create mode 100644 client/src/app/instance/store/reducers/svom-json-kw.reducer.spec.ts create mode 100644 client/src/app/instance/store/reducers/svom-json-kw.reducer.ts create mode 100644 client/src/app/instance/store/selectors/svom-json-kw.selector.spec.ts create mode 100644 client/src/app/instance/store/selectors/svom-json-kw.selector.ts create mode 100644 client/src/app/instance/store/services/svom-json-kw.service.spec.ts create mode 100644 client/src/app/instance/store/services/svom-json-kw.service.ts diff --git a/client/src/app/instance/instance.reducer.ts b/client/src/app/instance/instance.reducer.ts index 2c3b3c97..062c160c 100644 --- a/client/src/app/instance/instance.reducer.ts +++ b/client/src/app/instance/instance.reducer.ts @@ -15,6 +15,7 @@ import * as searchMultiple from './store/reducers/search-multiple.reducer'; import * as samp from './store/reducers/samp.reducer'; import * as coneSearch from './store/reducers/cone-search.reducer'; import * as detail from './store/reducers/detail.reducer'; +import * as svomJsonKw from './store/reducers/svom-json-kw.reducer'; /** * Interface for instance state. @@ -26,7 +27,8 @@ export interface State { searchMultiple: searchMultiple.State, samp: samp.State, coneSearch: coneSearch.State - detail: detail.State + detail: detail.State, + svomJsonKw: svomJsonKw.State } const reducers = { @@ -34,7 +36,8 @@ const reducers = { searchMultiple: searchMultiple.searchMultipleReducer, samp: samp.sampReducer, coneSearch: coneSearch.coneSearchReducer, - detail: detail.detailReducer + detail: detail.detailReducer, + svomJsonKw: svomJsonKw.svomJsonKwReducer }; export const instanceReducer = combineReducers(reducers); diff --git a/client/src/app/instance/search/components/criteria/criteria-by-family.component.html b/client/src/app/instance/search/components/criteria/criteria-by-family.component.html index 6fa2b430..d7fa428f 100644 --- a/client/src/app/instance/search/components/criteria/criteria-by-family.component.html +++ b/client/src/app/instance/search/components/criteria/criteria-by-family.component.html @@ -149,6 +149,9 @@ [label]="attribute.form_label" [criterion]="getCriterion(attribute.id)" [criteriaList]="criteriaList" + [svomKeywords]="svomKeywords" + (selectSvomAcronym)=selectSvomAcronym.emit($event) + (resetSvomKeywords)="resetSvomKeywords.emit()" (addCriterion)="emitAdd($event)" (deleteCriterion)="emitDelete($event)"> </app-svom-json-kw-criteria> diff --git a/client/src/app/instance/search/components/criteria/criteria-by-family.component.spec.ts b/client/src/app/instance/search/components/criteria/criteria-by-family.component.spec.ts index d019066b..946737df 100644 --- a/client/src/app/instance/search/components/criteria/criteria-by-family.component.spec.ts +++ b/client/src/app/instance/search/components/criteria/criteria-by-family.component.spec.ts @@ -3,7 +3,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { CriteriaByFamilyComponent } from './criteria-by-family.component'; import { Option } from '../../../../metamodel/models'; -import { Criterion, FieldCriterion } from '../../../store/models'; +import { Criterion, FieldCriterion, SvomKeyword } from '../../../store/models'; describe('[Instance][Search][Component][Criteria] CriteriaByFamilyComponent', () => { @Component({ selector: 'app-field', template: '' }) @@ -123,6 +123,15 @@ describe('[Instance][Search][Component][Criteria] CriteriaByFamilyComponent', () @Input() criterion: Criterion; } + @Component({ selector: 'app-svom-json-kw-criteria', template: '' }) + class SvomJsonStubKwComponent { + @Input() id: number; + @Input() label: string; + @Input() criterion: Criterion; + @Input() criteriaList: Criterion[]; + @Input() svomKeywords: SvomKeyword[]; + } + let component: CriteriaByFamilyComponent; let fixture: ComponentFixture<CriteriaByFamilyComponent>; @@ -142,7 +151,8 @@ describe('[Instance][Search][Component][Criteria] CriteriaByFamilyComponent', () BetweenDateStubComponent, TimeStubComponent, DatetimeStubComponent, - JsonStubComponent + JsonStubComponent, + SvomJsonStubKwComponent ] }); fixture = TestBed.createComponent(CriteriaByFamilyComponent); diff --git a/client/src/app/instance/search/components/criteria/criteria-by-family.component.ts b/client/src/app/instance/search/components/criteria/criteria-by-family.component.ts index ebfbed20..e64b8eb0 100644 --- a/client/src/app/instance/search/components/criteria/criteria-by-family.component.ts +++ b/client/src/app/instance/search/components/criteria/criteria-by-family.component.ts @@ -9,7 +9,7 @@ import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core'; -import { Criterion } from '../../../store/models'; +import { Criterion, SvomKeyword } from '../../../store/models'; import { Attribute, Option } from 'src/app/metamodel/models'; /** @@ -24,6 +24,9 @@ import { Attribute, Option } from 'src/app/metamodel/models'; export class CriteriaByFamilyComponent { @Input() attributeList: Attribute[]; @Input() criteriaList: Criterion[]; + @Input() svomKeywords: SvomKeyword[]; + @Output() selectSvomAcronym: EventEmitter<string> = new EventEmitter(); + @Output() resetSvomKeywords: EventEmitter<{}> = new EventEmitter(); @Output() addCriterion: EventEmitter<Criterion> = new EventEmitter(); @Output() deleteCriterion: EventEmitter<number> = new EventEmitter(); diff --git a/client/src/app/instance/search/components/criteria/criteria-tabs.component.html b/client/src/app/instance/search/components/criteria/criteria-tabs.component.html index fa9d3aef..d659ea65 100644 --- a/client/src/app/instance/search/components/criteria/criteria-tabs.component.html +++ b/client/src/app/instance/search/components/criteria/criteria-tabs.component.html @@ -16,6 +16,9 @@ <app-criteria-by-family [attributeList]="attributeList | attributeListByFamily:family.id" [criteriaList]="criteriaList" + [svomKeywords]="svomKeywords" + (selectSvomAcronym)="selectSvomAcronym.emit($event)" + (resetSvomKeywords)="resetSvomKeywords.emit()" (addCriterion)="emitAdd($event)" (deleteCriterion)="emitDelete($event)"> </app-criteria-by-family> diff --git a/client/src/app/instance/search/components/criteria/criteria-tabs.component.spec.ts b/client/src/app/instance/search/components/criteria/criteria-tabs.component.spec.ts index b2557821..11b0bca6 100644 --- a/client/src/app/instance/search/components/criteria/criteria-tabs.component.spec.ts +++ b/client/src/app/instance/search/components/criteria/criteria-tabs.component.spec.ts @@ -6,7 +6,7 @@ import { AccordionModule } from 'ngx-bootstrap/accordion'; import { CriteriaTabsComponent } from './criteria-tabs.component'; import { Attribute } from '../../../../metamodel/models'; -import { Criterion, FieldCriterion } from '../../../store/models'; +import { Criterion, FieldCriterion, SvomKeyword } from '../../../store/models'; import { AttributeListByFamilyPipe } from '../../../../shared/pipes/attribute-list-by-family.pipe'; describe('[Instance][Search][Component][Criteria] CriteriaTabsComponent', () => { @@ -14,6 +14,7 @@ describe('[Instance][Search][Component][Criteria] CriteriaTabsComponent', () => class CriteriaByFamilyStubComponent { @Input() attributeList: Attribute[]; @Input() criteriaList: Criterion[]; + @Input() svomKeywords: SvomKeyword[]; } let component: CriteriaTabsComponent; diff --git a/client/src/app/instance/search/components/criteria/criteria-tabs.component.ts b/client/src/app/instance/search/components/criteria/criteria-tabs.component.ts index a7c45275..7ed5b94d 100644 --- a/client/src/app/instance/search/components/criteria/criteria-tabs.component.ts +++ b/client/src/app/instance/search/components/criteria/criteria-tabs.component.ts @@ -9,7 +9,7 @@ import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core'; -import { Criterion } from '../../../store/models'; +import { Criterion, SvomKeyword } from '../../../store/models'; import { CriteriaFamily, Attribute } from 'src/app/metamodel/models'; /** @@ -25,6 +25,9 @@ export class CriteriaTabsComponent { @Input() attributeList: Attribute[]; @Input() criteriaFamilyList: CriteriaFamily[]; @Input() criteriaList: Criterion[]; + @Input() svomKeywords: SvomKeyword[]; + @Output() selectSvomAcronym: EventEmitter<string> = new EventEmitter(); + @Output() resetSvomKeywords: EventEmitter<{}> = new EventEmitter(); @Output() addCriterion: EventEmitter<Criterion> = new EventEmitter(); @Output() deleteCriterion: EventEmitter<number> = new EventEmitter(); diff --git a/client/src/app/instance/search/components/criteria/search-type/json.component.html b/client/src/app/instance/search/components/criteria/search-type/json.component.html index 8d69dac7..3780ed1f 100644 --- a/client/src/app/instance/search/components/criteria/search-type/json.component.html +++ b/client/src/app/instance/search/components/criteria/search-type/json.component.html @@ -8,14 +8,13 @@ </div> <div class="w-100 d-block d-sm-none"></div> <div class="col col-sm-auto mb-1 mb-sm-0 px-sm-0"> - <select class="custom-select" id="operator" name="operator" formControlName="operator"> - <option></option> - <option value="eq">=</option> - <option value="gt">></option> - <option value="gte">>=</option> - <option value="lt"><</option> - <option value="lte"><=</option> - </select> + <ng-select [clearable]="false" [multiple]="false" [hideSelected]="true" class="ng-select-custom" formControlName="operator"> + <ng-option value="eq">=</ng-option > + <ng-option value="gt">></ng-option > + <ng-option value="gte">>=</ng-option > + <ng-option value="lt"><</ng-option > + <ng-option value="lte"><=</ng-option > + </ng-select> </div> <div class="w-100 d-block d-sm-none"></div> <div class="col"> diff --git a/client/src/app/instance/search/components/criteria/search-type/json.component.spec.ts b/client/src/app/instance/search/components/criteria/search-type/json.component.spec.ts index 6941d117..673739f3 100644 --- a/client/src/app/instance/search/components/criteria/search-type/json.component.spec.ts +++ b/client/src/app/instance/search/components/criteria/search-type/json.component.spec.ts @@ -2,6 +2,8 @@ import { Component, ViewChild } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { NgSelectModule } from '@ng-select/ng-select'; + import { JsonComponent } from './json.component'; import { JsonCriterion } from '../../../../store/models/criterion'; @@ -35,7 +37,8 @@ describe('[Instance][Search][Component][Criteria][SearchType] JsonComponent', () ], imports: [ FormsModule, - ReactiveFormsModule + ReactiveFormsModule, + NgSelectModule ] }); testHostFixture = TestBed.createComponent(TestHostComponent); diff --git a/client/src/app/instance/search/components/criteria/search-type/svom-json-kw.component.html b/client/src/app/instance/search/components/criteria/search-type/svom-json-kw.component.html index 1f94ffca..7257b997 100644 --- a/client/src/app/instance/search/components/criteria/search-type/svom-json-kw.component.html +++ b/client/src/app/instance/search/components/criteria/search-type/svom-json-kw.component.html @@ -4,26 +4,20 @@ <label>{{ label }}</label> <div class="row"> <div class="col mb-1 mb-sm-0"> - <input *ngIf="!acronym" class="form-control" name="ext" placeholder="Ext" autocomplete="off" formControlName="ext"> - <select *ngIf="acronym" class="form-control" name="ext" formControlName="ext" (change)="extOnChange()"> - <option></option> - <option *ngFor="let ext of exts" [ngValue]="ext">{{ ext }}</option> - </select> - </div> - <div class="w-100 d-block d-sm-none"></div> - <div class="col mb-1 mb-sm-0 pl-sm-0 pl-md-0 pl-lg-0"> - <input *ngIf="!acronym" class="form-control" id="keyword" name="keyword" placeholder="Keyword" autocomplete="off" formControlName="keyword"> + <input *ngIf="svomKeywords.length < 1" class="form-control" id="path" name="path" placeholder="Path" autocomplete="off" formControlName="path"> + <ng-select *ngIf="svomKeywords.length > 0" [multiple]="false" [hideSelected]="true" placeholder="Select svom product keyword" class="ng-select-custom" formControlName="path"> + <ng-option *ngFor="let svomKeyword of svomKeywords" [value]="getKeywordValue(svomKeyword)">{{ svomKeyword.extension }}/{{ svomKeyword.name }}</ng-option> + </ng-select> </div> <div class="w-100 d-block d-sm-none"></div> <div class="col col-sm-auto mb-1 mb-sm-0 px-sm-0"> - <select class="custom-select" id="operator" name="operator" formControlName="operator"> - <option></option> - <option value="eq">=</option> - <option value="gt">></option> - <option value="gte">>=</option> - <option value="lt"><</option> - <option value="lte"><=</option> - </select> + <ng-select [clearable]="false" [multiple]="false" [hideSelected]="true" class="ng-select-custom" formControlName="operator"> + <ng-option value="eq">=</ng-option > + <ng-option value="gt">></ng-option > + <ng-option value="gte">>=</ng-option > + <ng-option value="lt"><</ng-option > + <ng-option value="lte"><=</ng-option > + </ng-select> </div> <div class="w-100 d-block d-sm-none"></div> <div class="col"> diff --git a/client/src/app/instance/search/components/criteria/search-type/svom-json-kw.component.spec.ts b/client/src/app/instance/search/components/criteria/search-type/svom-json-kw.component.spec.ts new file mode 100644 index 00000000..05f55eec --- /dev/null +++ b/client/src/app/instance/search/components/criteria/search-type/svom-json-kw.component.spec.ts @@ -0,0 +1,93 @@ +import { Component, ViewChild } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; + +import { NgSelectModule } from '@ng-select/ng-select'; + +import { SvomJsonKwComponent } from './svom-json-kw.component'; +import { Criterion, JsonCriterion, SvomKeyword } from '../../../../store/models'; + +describe('[Instance][Search][Component][Criteria][SearchType] SvomJsonKwComponent', () => { + @Component({ + selector: `app-host`, + template: ` + <app-svom-json-kw-criteria + [id]="id" + [label]="label" + [criterion]="criterion" + [criteriaList]="criteriaList" + [svomKeywords]="svomKeywords"> + </app-svom-json-kw-criteria >` + }) + class TestHostComponent { + @ViewChild(SvomJsonKwComponent, { static: false }) + public testedComponent: SvomJsonKwComponent; + public id: number = undefined; + public label: string = undefined; + public criterion: JsonCriterion = undefined; + public criteriaList: Criterion[] = []; + public svomKeywords: SvomKeyword[] = []; + } + + let testHostComponent: TestHostComponent; + let testHostFixture: ComponentFixture<TestHostComponent>; + let testedComponent: SvomJsonKwComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ + SvomJsonKwComponent, + TestHostComponent + ], + imports: [ + FormsModule, + ReactiveFormsModule, + NgSelectModule + ] + }); + testHostFixture = TestBed.createComponent(TestHostComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + testedComponent = testHostComponent.testedComponent; + }); + + it('should create the component', () => { + expect(testedComponent).toBeTruthy(); + }); + + it('should call ngOnChanges and apply changes', () => { + const spy = jest.spyOn(testedComponent, 'ngOnChanges'); + testHostComponent.criterion = { id: testedComponent.id, type: 'json', path: 'myPath', operator: 'myOperator', value: 'myValue' } as JsonCriterion; + testHostFixture.detectChanges(); + expect(testedComponent.form.controls.path.value).toEqual('myPath'); + expect(testedComponent.form.controls.operator.value).toEqual('myOperator'); + expect(testedComponent.form.controls.value.value).toEqual('myValue'); + expect(testedComponent.form.disabled).toBeTruthy(); + testHostComponent.criterion = undefined; + testHostFixture.detectChanges(); + expect(testedComponent.form.controls.path.value).toBeNull(); + expect(testedComponent.form.controls.operator.value).toBeNull(); + expect(testedComponent.form.controls.value.value).toBeNull(); + expect(testedComponent.form.enabled).toBeTruthy(); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('raises the add criterion event when clicked', () => { + testedComponent.id = 1; + testedComponent.form.controls.path.setValue('myPath'); + testedComponent.form.controls.operator.setValue('myOperator'); + testedComponent.form.controls.value.setValue('myValue'); + const expectedCriterion = { id: testedComponent.id, type: 'json', path: 'myPath', operator: 'myOperator', value: 'myValue' } as JsonCriterion; + testedComponent.addCriterion.subscribe((event: JsonCriterion) => expect(event).toEqual(expectedCriterion)); + testedComponent.emitAdd(); + }); + + it('Calculates the value of ng-option', () => { + expect(testedComponent.getKeywordValue({ + extension: 'PrimaryHDU', + name: 'CARD', + data_type: 'string', + default: '' + })).toEqual('PrimaryHDU,CARD'); + }); +}); diff --git a/client/src/app/instance/search/components/criteria/search-type/svom-json-kw.component.ts b/client/src/app/instance/search/components/criteria/search-type/svom-json-kw.component.ts index 57c2f4f1..9feb30f5 100644 --- a/client/src/app/instance/search/components/criteria/search-type/svom-json-kw.component.ts +++ b/client/src/app/instance/search/components/criteria/search-type/svom-json-kw.component.ts @@ -8,11 +8,9 @@ */ import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, OnChanges, SimpleChanges } from '@angular/core'; -import {Â HttpClient } from '@angular/common/http'; import { FormGroup, FormControl, Validators } from '@angular/forms'; -import { JsonCriterion, Criterion } from 'src/app/instance/store/models'; -import { AppConfigService } from 'src/app/app-config.service'; +import { JsonCriterion, Criterion, SvomKeyword } from 'src/app/instance/store/models'; @Component({ selector: 'app-svom-json-kw-criteria', @@ -24,19 +22,14 @@ export class SvomJsonKwComponent implements OnChanges { @Input() label: string; @Input() criterion: Criterion; @Input() criteriaList: Criterion[]; + @Input() svomKeywords: SvomKeyword[]; + @Output() selectSvomAcronym: EventEmitter<string> = new EventEmitter(); + @Output() resetSvomKeywords: EventEmitter<{}> = new EventEmitter(); @Output() addCriterion: EventEmitter<JsonCriterion> = new EventEmitter(); @Output() deleteCriterion: EventEmitter<number> = new EventEmitter(); - acronym = false; - keywordsSearchable = null; - exts: string[] = []; - keywords: string[] = []; - - public constructor(private httpClient:Â HttpClient, private config: AppConfigService) { } - public form = new FormGroup({ - ext: new FormControl('', [Validators.required]), - keyword: new FormControl('', [Validators.required]), + path: new FormControl('', [Validators.required]), operator: new FormControl('', [Validators.required]), value: new FormControl('', [Validators.required]) }); @@ -52,25 +45,23 @@ export class SvomJsonKwComponent implements OnChanges { this.form.reset(); } - if (changes.criteriaList && changes.criteriaList.currentValue.find((c: Criterion) => c.id === 3)) { - this.acronym = true; - this.getKwSearchable(changes.criteriaList.currentValue.find((c: Criterion) => c.id === 3).value); + if (changes.criteriaList && this.svomKeywords.length < 1 && changes.criteriaList.currentValue.find((c: Criterion) => c.id === 3)) { + this.selectSvomAcronym.emit(changes.criteriaList.currentValue.find((c: Criterion) => c.id === 3).value); } - } - getKwSearchable(acronym: string) { - this.httpClient.get(`${this.config.apiUrl}/search/sp_cards?a=8&c=1::eq::${acronym}`).subscribe(data => { - this.keywordsSearchable = data[0].search_kw; - this.exts = [...new Set<string>(this.keywordsSearchable.map(item => item.extension))]; - console.log(data); - /* this.exts = this.keywordsSearchable - .map(item => item.extension) - .filter((value, index, self) => self.indexOf(value.) === index); */ - }); + if (changes.criteriaList && this.svomKeywords.length > 0 && !changes.criteriaList.currentValue.find((c: Criterion) => c.id === 3)) { + this.resetSvomKeywords.emit(); + } } - extOnChange() { - + /** + * Transform a SVOM json Keyword to as path value (anis json search) + * + * @param svomKeyword Keyword selected by user + * @returns string path value + */ + getKeywordValue(svomKeyword: SvomKeyword): string { + return `${svomKeyword.extension},${svomKeyword.name}` } /** @@ -79,12 +70,7 @@ export class SvomJsonKwComponent implements OnChanges { * @fires EventEmitter<JsonCriterion> */ emitAdd(): void { - const formValue = { - path: `${this.form.controls.ext.value},${this.form.controls.keyword.value}`, - operator: this.form.controls.operator.value, - value: this.form.controls.value.value - } - const js = { id: this.id, type: 'json', ...formValue }; + const js = { id: this.id, type: 'json', ...this.form.value }; this.addCriterion.emit(js); } } diff --git a/client/src/app/instance/search/containers/criteria.component.html b/client/src/app/instance/search/containers/criteria.component.html index 0afddb0a..c24a1901 100644 --- a/client/src/app/instance/search/containers/criteria.component.html +++ b/client/src/app/instance/search/containers/criteria.component.html @@ -22,6 +22,9 @@ [attributeList]="attributeList | async | sortByCriteriaDisplay" [criteriaFamilyList]="criteriaFamilyList | async" [criteriaList]="criteriaList | async" + [svomKeywords]="svomKeywords | async" + (selectSvomAcronym)="selectSvomAcronym($event)" + (resetSvomKeywords)="resetSvomKeywords()" (addCriterion)="addCriterion($event)" (deleteCriterion)="deleteCriterion($event)"> </app-criteria-tabs> diff --git a/client/src/app/instance/search/containers/criteria.component.spec.ts b/client/src/app/instance/search/containers/criteria.component.spec.ts index a1c1e2eb..035339c2 100644 --- a/client/src/app/instance/search/containers/criteria.component.spec.ts +++ b/client/src/app/instance/search/containers/criteria.component.spec.ts @@ -6,7 +6,7 @@ import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { CriteriaComponent } from './criteria.component'; import { Attribute, CriteriaFamily, Dataset, OutputCategory, OutputFamily } from '../../../metamodel/models'; -import { ConeSearch, Criterion, Resolver, SearchQueryParams } from '../../store/models'; +import { ConeSearch, Criterion, Resolver, SearchQueryParams, SvomKeyword } from '../../store/models'; import { SortByCriteriaDisplayPipe } from '../pipes/sort-by-criteria-display.pipe'; import * as searchActions from '../../store/actions/search.actions'; import { AbstractSearchComponent } from './abstract-search.component'; @@ -31,6 +31,7 @@ describe('[Instance][Search][Container] CriteriaComponent', () => { @Input() attributeList: Attribute[]; @Input() criteriaFamilyList: CriteriaFamily[]; @Input() criteriaList: Criterion[]; + @Input() svomKeywords: SvomKeyword[]; } @Component({ selector: 'app-summary', template: '' }) diff --git a/client/src/app/instance/search/containers/criteria.component.ts b/client/src/app/instance/search/containers/criteria.component.ts index 2530489e..a73921a9 100644 --- a/client/src/app/instance/search/containers/criteria.component.ts +++ b/client/src/app/instance/search/containers/criteria.component.ts @@ -13,10 +13,12 @@ import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; import { AbstractSearchComponent } from './abstract-search.component'; -import { ConeSearch, Criterion, Resolver } from '../../store/models'; +import { ConeSearch, Criterion, Resolver, SvomKeyword } from '../../store/models'; import * as searchActions from '../../store/actions/search.actions'; import * as coneSearchActions from '../../store/actions/cone-search.actions'; import * as coneSearchSelector from '../../store/selectors/cone-search.selector'; +import * as svomJsonKwActions from '../../store/actions/svom-json-kw.actions'; +import * as svomJsonKwSelector from '../../store/selectors/svom-json-kw.selector'; /** * @class @@ -30,12 +32,14 @@ export class CriteriaComponent extends AbstractSearchComponent { public resolver: Observable<Resolver>; public resolverIsLoading: Observable<boolean>; public resolverIsLoaded: Observable<boolean>; + public svomKeywords: Observable<SvomKeyword[]>; constructor(protected store: Store<{ }>) { super(store); this.resolver = this.store.select(coneSearchSelector.selectResolver); this.resolverIsLoading = this.store.select(coneSearchSelector.selectResolverIsLoading); this.resolverIsLoaded = this.store.select(coneSearchSelector.selectResolverIsLoaded); + this.svomKeywords = this.store.select(svomJsonKwSelector.selectSvomKeywords); } ngOnInit(): void { @@ -88,4 +92,12 @@ export class CriteriaComponent extends AbstractSearchComponent { retrieveCoordinates(name: string): void { this.store.dispatch(coneSearchActions.retrieveCoordinates({ name })); } + + selectSvomAcronym(acronymSelected: string): void { + this.store.dispatch(svomJsonKwActions.selectAcronym({ acronymSelected })); + } + + resetSvomKeywords(): void { + Promise.resolve(null).then(() => this.store.dispatch(svomJsonKwActions.resetKw())); + } } diff --git a/client/src/app/instance/store/actions/svom-json-kw.actions.ts b/client/src/app/instance/store/actions/svom-json-kw.actions.ts new file mode 100644 index 00000000..4ce485e9 --- /dev/null +++ b/client/src/app/instance/store/actions/svom-json-kw.actions.ts @@ -0,0 +1,18 @@ +/** + * 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 { SvomKeyword } from '../models'; + +export const resetKw = createAction('[SVOM Json Kw] Reset Kw'); +export const selectAcronym = createAction('[SVOM Json Kw] Select Acronym', props<{ acronymSelected: string }>()); +export const loadKwSearchable = createAction('[SVOM Json Kw] Load Kw Searchable'); +export const loadKwSearchableSuccess = createAction('[SVOM Json Kw] Load Kw Searchable Success', props<{ svomKeywords: SvomKeyword[] }>()); +export const loadKwSearchableFail = createAction('[SVOM Json Kw] Load Kw Searchable Fail'); \ No newline at end of file diff --git a/client/src/app/instance/store/effects/index.ts b/client/src/app/instance/store/effects/index.ts index 582dd511..69b9a3b2 100644 --- a/client/src/app/instance/store/effects/index.ts +++ b/client/src/app/instance/store/effects/index.ts @@ -3,11 +3,13 @@ import { SearchEffects } from './search.effects'; import { SearchMultipleEffects } from './search-multiple.effects'; import { ConeSearchEffects } from './cone-search.effects'; import { DetailEffects } from './detail.effects'; +import { SvomJsonKwEffects } from './svom-json-kw.effects'; export const instanceEffects = [ SampEffects, SearchEffects, SearchMultipleEffects, ConeSearchEffects, - DetailEffects + DetailEffects, + SvomJsonKwEffects ]; diff --git a/client/src/app/instance/store/effects/svom-json-kw.effects.spec.ts b/client/src/app/instance/store/effects/svom-json-kw.effects.spec.ts new file mode 100644 index 00000000..ed3ca655 --- /dev/null +++ b/client/src/app/instance/store/effects/svom-json-kw.effects.spec.ts @@ -0,0 +1,44 @@ +import { TestBed } from '@angular/core/testing'; + +import { provideMockActions } from '@ngrx/effects/testing'; +import { EffectsMetadata, getEffectsMetadata } from '@ngrx/effects'; +import { provideMockStore } from '@ngrx/store/testing'; +import { Observable } from 'rxjs'; +import { ToastrService } from 'ngx-toastr'; + +import { SvomJsonKwEffects } from './svom-json-kw.effects'; +import { SvomJsonKwService } from '../services/svom-json-kw.service'; +import * as fromSvomJsonKw from '../reducers/svom-json-kw.reducer'; + +describe('[Instance][Store] SvomJsonKwEffects', () => { + let actions = new Observable(); + let effects: SvomJsonKwEffects; + let metadata: EffectsMetadata<SvomJsonKwEffects>; + let svomJsonKwService: SvomJsonKwService; + let toastr: ToastrService; + const initialState = { + instance: { + svomJsonKw: { ...fromSvomJsonKw.initialState } + } + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + SvomJsonKwEffects, + { provide: SvomJsonKwService, useValue: { loadKwSearchable: jest.fn() }}, + { provide: ToastrService, useValue: { success: jest.fn(), error: jest.fn() }}, + provideMockActions(() => actions), + provideMockStore({ initialState }) + ] + }).compileComponents(); + effects = TestBed.inject(SvomJsonKwEffects); + metadata = getEffectsMetadata(effects); + svomJsonKwService = TestBed.inject(SvomJsonKwService); + toastr = TestBed.inject(ToastrService); + }); + + it('should be created', () => { + expect(effects).toBeTruthy(); + }); +}); diff --git a/client/src/app/instance/store/effects/svom-json-kw.effects.ts b/client/src/app/instance/store/effects/svom-json-kw.effects.ts new file mode 100644 index 00000000..c6d32c75 --- /dev/null +++ b/client/src/app/instance/store/effects/svom-json-kw.effects.ts @@ -0,0 +1,71 @@ +/** + * 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 svomJsonKwActions from '../actions/svom-json-kw.actions'; +import * as svomJsonKwSelector from '../selectors/svom-json-kw.selector'; + +import { SvomJsonKwService } from '../services/svom-json-kw.service'; + +/** + * @class + * @classdesc Svom Json Kw effects. + */ +@Injectable() +export class SvomJsonKwEffects { + selectAcronym$ = createEffect((): any => + this.actions$.pipe( + ofType(svomJsonKwActions.selectAcronym), + map(() => svomJsonKwActions.loadKwSearchable()) + ) + ); + + loadKwSearchable$ = createEffect(() => + this.actions$.pipe( + ofType(svomJsonKwActions.loadKwSearchable), + concatLatestFrom(() => this.store.select(svomJsonKwSelector.selectAcronymSelected)), + mergeMap(([action, acronymSelected]) => this.svomJsonKwService.loadKwSearchable(acronymSelected) + .pipe( + map(svomKeywords => svomJsonKwActions.loadKwSearchableSuccess({ svomKeywords })), + catchError(() => of(svomJsonKwActions.loadKwSearchableFail())) + ) + ) + ) + ); + + loadKwSearchableSuccess$ = createEffect(() => + this.actions$.pipe( + ofType(svomJsonKwActions.loadKwSearchableSuccess), + tap(() => { + this.toastr.success('SVOM Json Keywords was loaded successfully and are now available to product criteria', 'SVOM Json Keywords loaded') + }) + ), { dispatch: false} + ); + + addDatabaseFail$ = createEffect(() => + this.actions$.pipe( + ofType(svomJsonKwActions.loadKwSearchableFail), + tap(() => this.toastr.error('Failure to load Keywords', 'SVOM Json Keywords loaded failed')) + ), { dispatch: false} + ); + + constructor( + private actions$: Actions, + private store: Store<{ }>, + private svomJsonKwService: SvomJsonKwService, + private toastr: ToastrService + ) {} +} diff --git a/client/src/app/instance/store/models/index.ts b/client/src/app/instance/store/models/index.ts index 962eede1..2570f163 100644 --- a/client/src/app/instance/store/models/index.ts +++ b/client/src/app/instance/store/models/index.ts @@ -7,3 +7,4 @@ export * from './cone-search.model'; export * from './resolver.model'; export * from './search-multiple-dataset-length'; export * from './search-multiple-dataset-data'; +export * from './svom-keyword.model'; \ No newline at end of file diff --git a/client/src/app/instance/store/models/svom-keyword.model.ts b/client/src/app/instance/store/models/svom-keyword.model.ts new file mode 100644 index 00000000..1fabc699 --- /dev/null +++ b/client/src/app/instance/store/models/svom-keyword.model.ts @@ -0,0 +1,6 @@ +export interface SvomKeyword { + data_type: string + default: string + extension: string + name: string +} \ No newline at end of file diff --git a/client/src/app/instance/store/reducers/svom-json-kw.reducer.spec.ts b/client/src/app/instance/store/reducers/svom-json-kw.reducer.spec.ts new file mode 100644 index 00000000..b5c3ae74 --- /dev/null +++ b/client/src/app/instance/store/reducers/svom-json-kw.reducer.spec.ts @@ -0,0 +1,82 @@ +import * as fromSvomJsonKw from './svom-json-kw.reducer'; +import * as svomJsonKwActions from '../actions/svom-json-kw.actions'; + +describe('[Instance][Store] Svom Json Kw reducer', () => { + it('unknown action should return the default state', () => { + const { initialState } = fromSvomJsonKw; + const action = { type: 'Unknown' }; + const state = fromSvomJsonKw.svomJsonKwReducer(initialState, action); + + expect(state).toBe(initialState); + }); + + it('resetKw action should return the default state', () => { + const { initialState } = fromSvomJsonKw; + const action = svomJsonKwActions.resetKw(); + const state = fromSvomJsonKw.svomJsonKwReducer(initialState, action); + + expect(state).toEqual(initialState); + }); + + it('selectAcronym action should change the acronymSelected', () => { + const { initialState } = fromSvomJsonKw; + const action = svomJsonKwActions.selectAcronym({ acronymSelected: 'OBLC_ECL' }); + const state = fromSvomJsonKw.svomJsonKwReducer(initialState, action); + + expect(state.acronymSelected).toBe('OBLC_ECL'); + expect(state.svomKeywords).toEqual([]); + expect(state.svomKeywordsIsLoading).toBeFalsy(); + expect(state.svomKeywordsIsLoaded).toBeFalsy(); + expect(state).not.toBe(initialState); + }); + + it('loadKwSearchable action should change the svomKeywordsLoading and svomKeywordsLoaded', () => { + const { initialState } = fromSvomJsonKw; + const action = svomJsonKwActions.loadKwSearchable(); + const state = fromSvomJsonKw.svomJsonKwReducer(initialState, action); + + expect(state.acronymSelected).toBeNull(); + expect(state.svomKeywords).toEqual([]); + expect(state.svomKeywordsIsLoading).toBeTruthy(); + expect(state.svomKeywordsIsLoaded).toBeFalsy(); + expect(state).not.toBe(initialState); + }); + + it('loadKwSearchableSuccess action should change the svomKeywords, svomKeywordsLoading and svomKeywordsLoaded', () => { + const { initialState } = fromSvomJsonKw; + const svomKeywords = [ + { + data_type: 'string', + default: '', + extension: 'PrimaryHDU', + name: 'CARD' + }, + { + data_type: 'string', + default: '', + extension: 'PrimaryHDU', + name: 'TIME' + } + ]; + const action = svomJsonKwActions.loadKwSearchableSuccess({ svomKeywords }); + const state = fromSvomJsonKw.svomJsonKwReducer(initialState, action); + + expect(state.acronymSelected).toBeNull(); + expect(state.svomKeywords).toEqual(svomKeywords); + expect(state.svomKeywordsIsLoading).toBeFalsy(); + expect(state.svomKeywordsIsLoaded).toBeTruthy(); + expect(state).not.toBe(initialState); + }); + + it('loadKwSearchableFail action should change the svomKeywordsLoading and svomKeywordsLoaded', () => { + const { initialState } = fromSvomJsonKw; + const action = svomJsonKwActions.loadKwSearchableFail(); + const state = fromSvomJsonKw.svomJsonKwReducer(initialState, action); + + expect(state.acronymSelected).toBeNull(); + expect(state.svomKeywords).toEqual([]); + expect(state.svomKeywordsIsLoading).toBeFalsy(); + expect(state.svomKeywordsIsLoaded).toBeFalsy(); + expect(state).not.toBe(initialState); + }); +}); diff --git a/client/src/app/instance/store/reducers/svom-json-kw.reducer.ts b/client/src/app/instance/store/reducers/svom-json-kw.reducer.ts new file mode 100644 index 00000000..37c85260 --- /dev/null +++ b/client/src/app/instance/store/reducers/svom-json-kw.reducer.ts @@ -0,0 +1,64 @@ +/** + * 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 { SvomKeyword } from '../models'; +import * as svomJsonKwActions from '../actions/svom-json-kw.actions'; + +/** + * Interface for svom json kw state. + * + * @interface State + */ +export interface State { + acronymSelected: string; + svomKeywords: SvomKeyword[]; + svomKeywordsIsLoading: boolean; + svomKeywordsIsLoaded: boolean; +} + +export const initialState: State = { + acronymSelected: null, + svomKeywords: [], + svomKeywordsIsLoading: false, + svomKeywordsIsLoaded: false +}; + +export const svomJsonKwReducer = createReducer( + initialState, + on(svomJsonKwActions.resetKw, () => ({ + ...initialState + })), + on(svomJsonKwActions.selectAcronym, (state, { acronymSelected }) => ({ + ...state, + acronymSelected + })), + on(svomJsonKwActions.loadKwSearchable, (state) => ({ + ...state, + svomKeywordsIsLoading: true, + svomKeywordsIsLoaded: false + })), + on(svomJsonKwActions.loadKwSearchableSuccess, (state, { svomKeywords }) => ({ + ...state, + svomKeywords, + svomKeywordsIsLoading: false, + svomKeywordsIsLoaded: true + })), + on(svomJsonKwActions.loadKwSearchableFail, (state) => ({ + ...state, + svomKeywordsIsLoading: false, + svomKeywordsIsLoaded: false + })), +); + +export const selectAcronymSelected = (state: State) => state.acronymSelected; +export const selectSvomKeywords = (state: State) => state.svomKeywords; +export const selectSvomKeywordsIsLoading = (state: State) => state.svomKeywordsIsLoading; +export const selectSvomKeywordsIsLoaded = (state: State) => state.svomKeywordsIsLoaded; diff --git a/client/src/app/instance/store/selectors/svom-json-kw.selector.spec.ts b/client/src/app/instance/store/selectors/svom-json-kw.selector.spec.ts new file mode 100644 index 00000000..e3e9604c --- /dev/null +++ b/client/src/app/instance/store/selectors/svom-json-kw.selector.spec.ts @@ -0,0 +1,24 @@ +import * as svomJsonKwSelector from './svom-json-kw.selector'; +import * as fromSvomJsonKw from '../reducers/svom-json-kw.reducer'; + +describe('[Instance][Store] Svom Json Kw selector', () => { + it('should get selectAcronymSelected', () => { + const state = { instance: { svomJsonKw: { ...fromSvomJsonKw.initialState }}}; + expect(svomJsonKwSelector.selectAcronymSelected(state)).toBeNull(); + }); + + it('should get selectSvomKeywords', () => { + const state = { instance: { svomJsonKw: { ...fromSvomJsonKw.initialState }}}; + expect(svomJsonKwSelector.selectSvomKeywords(state)).toEqual([]); + }); + + it('should get selectSvomKeywordsIsLoading', () => { + const state = { instance: { svomJsonKw: { ...fromSvomJsonKw.initialState }}}; + expect(svomJsonKwSelector.selectSvomKeywordsIsLoading(state)).toBeFalsy(); + }); + + it('should get selectSvomKeywordsIsLoaded', () => { + const state = { instance: { svomJsonKw: { ...fromSvomJsonKw.initialState }}}; + expect(svomJsonKwSelector.selectSvomKeywordsIsLoaded(state)).toBeFalsy(); + }); +}); diff --git a/client/src/app/instance/store/selectors/svom-json-kw.selector.ts b/client/src/app/instance/store/selectors/svom-json-kw.selector.ts new file mode 100644 index 00000000..c178ec62 --- /dev/null +++ b/client/src/app/instance/store/selectors/svom-json-kw.selector.ts @@ -0,0 +1,38 @@ +/** + * 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 '../../instance.reducer'; +import * as svomJsonKw from '../reducers/svom-json-kw.reducer'; + +export const selectSvomJsonKwState = createSelector( + reducer.getInstanceState, + (state: reducer.State) => state.svomJsonKw +); + +export const selectAcronymSelected = createSelector( + selectSvomJsonKwState, + svomJsonKw.selectAcronymSelected +); + +export const selectSvomKeywords = createSelector( + selectSvomJsonKwState, + svomJsonKw.selectSvomKeywords +); + +export const selectSvomKeywordsIsLoading = createSelector( + selectSvomJsonKwState, + svomJsonKw.selectSvomKeywordsIsLoading +); + +export const selectSvomKeywordsIsLoaded = createSelector( + selectSvomJsonKwState, + svomJsonKw.selectSvomKeywordsIsLoaded +); diff --git a/client/src/app/instance/store/services/index.ts b/client/src/app/instance/store/services/index.ts index cabc0796..795315e9 100644 --- a/client/src/app/instance/store/services/index.ts +++ b/client/src/app/instance/store/services/index.ts @@ -2,10 +2,12 @@ import { SearchService } from './search.service'; import { SampService } from './samp.service'; import { ConeSearchService } from './cone-search.service'; import { DetailService } from './detail.service'; +import { SvomJsonKwService } from './svom-json-kw.service'; export const instanceServices = [ SearchService, SampService, ConeSearchService, - DetailService + DetailService, + SvomJsonKwService ]; diff --git a/client/src/app/instance/store/services/svom-json-kw.service.spec.ts b/client/src/app/instance/store/services/svom-json-kw.service.spec.ts new file mode 100644 index 00000000..6a334fd2 --- /dev/null +++ b/client/src/app/instance/store/services/svom-json-kw.service.spec.ts @@ -0,0 +1,48 @@ +/** + * 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 { TestBed, inject } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; + +import { SvomJsonKwService } from './svom-json-kw.service'; +import { AppConfigService } from 'src/app/app-config.service'; + +describe('[Instance][Store] SvomJsonKwService', () => { + let service: SvomJsonKwService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + { provide: AppConfigService, useValue: { apiUrl: 'http://testing.com' } }, + SvomJsonKwService + ] + }); + service = TestBed.inject(SvomJsonKwService); + }); + + it('#retrieveData() should return an Observable<any[]>', + inject([HttpTestingController, SvomJsonKwService],(httpMock: HttpTestingController, svomJsonKwService: SvomJsonKwService) => { + const mockResponse = ['myData']; + + svomJsonKwService.loadKwSearchable('OBLC_ECL').subscribe((event: any[]) => { + expect(event).toEqual(mockResponse); + }); + + const mockRequest = httpMock.expectOne('http://testing.com/search/sp_cards?a=8&c=1::eq::OBLC_ECL'); + + expect(mockRequest.cancelled).toBeFalsy(); + expect(mockRequest.request.responseType).toEqual('json'); + mockRequest.flush(mockResponse); + + httpMock.verify(); + } + ) + ); +}); \ No newline at end of file diff --git a/client/src/app/instance/store/services/svom-json-kw.service.ts b/client/src/app/instance/store/services/svom-json-kw.service.ts new file mode 100644 index 00000000..7a990aa7 --- /dev/null +++ b/client/src/app/instance/store/services/svom-json-kw.service.ts @@ -0,0 +1,30 @@ +/** + * 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 { map } from 'rxjs/operators'; + +import { AppConfigService } from 'src/app/app-config.service'; +import { SvomKeyword } from '../models'; + +/** + * @class + * @classdesc Svom Json Kw service. + */ +@Injectable() +export class SvomJsonKwService { + constructor(private http: HttpClient, private config: AppConfigService) { } + + loadKwSearchable(acronym: string) { + return this.http.get<{search_kw: SvomKeyword[]}[]>(`${this.config.apiUrl}/search/sp_cards?a=8&c=1::eq::${acronym}`).pipe( + map(data => data[0].search_kw) + ); + } +} diff --git a/client/src/styles.scss b/client/src/styles.scss index db95f8b3..dc2941c0 100644 --- a/client/src/styles.scss +++ b/client/src/styles.scss @@ -44,6 +44,14 @@ main { margin-top: 100px; } +.ng-select-container { + height: 38px !important; +} + +.ng-select.ng-select-disabled>.ng-select-container { + background-color: #e9ecef !important; +} + .custom-switch label, .custom-radio label { cursor: pointer; } -- GitLab