diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 990fff494d..5c32a65511 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-len */ import { ClipboardModule } from '@angular/cdk/clipboard'; import { CommonModule } from '@angular/common'; import { HttpClient, HttpClientModule } from '@angular/common/http'; @@ -147,6 +148,24 @@ import { ListViewComponent } from './workspace/results/list-view/list-view.compo import { ResourceGridComponent } from './workspace/results/list-view/resource-grid/resource-grid.component'; import { ResourceListComponent } from './workspace/results/list-view/resource-list/resource-list.component'; import { ComparisonComponent } from './workspace/comparison/comparison.component'; +import { SearchPanelComponent } from './workspace/search/search-panel/search-panel.component'; +import { FulltextSearchComponent } from './workspace/search/fulltext-search/fulltext-search.component'; +import { ExpertSearchComponent } from './workspace/search/expert-search/expert-search.component'; +import { AdvancedSearchComponent } from './workspace/search/advanced-search/advanced-search.component'; +import { SearchBooleanValueComponent } from './workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-boolean-value/search-boolean-value.component'; +import { SearchDateValueComponent } from './workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-date-value/search-date-value.component'; +import { SearchDecimalValueComponent } from './workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-decimal-value/search-decimal-value.component'; +import { SearchIntValueComponent } from './workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-int-value/search-int-value.component'; +import { SearchLinkValueComponent } from './workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-link-value/search-link-value.component'; +import { SearchListValueComponent } from './workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-list-value/search-list-value.component'; +import { SearchDisplayListComponent } from './workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-list-value/search-display-list/search-display-list.component'; +import { SearchTextValueComponent } from './workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-text-value/search-text-value.component'; +import { SearchUriValueComponent } from './workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-uri-value/search-uri-value.component'; +import { SpecifyPropertyValueComponent } from './workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/specify-property-value.component'; +import { ResourceAndPropertySelectionComponent } from './workspace/search/advanced-search/resource-and-property-selection/resource-and-property-selection.component'; +import { SearchSelectPropertyComponent } from './workspace/search/advanced-search/resource-and-property-selection/search-select-property/search-select-property.component'; +import { SearchSelectResourceClassComponent } from './workspace/search/advanced-search/resource-and-property-selection/search-select-resource-class/search-select-resource-class.component'; +import { SearchSelectOntologyComponent } from './workspace/search/advanced-search/search-select-ontology/search-select-ontology.component'; // translate: AoT requires an exported function for factories export function httpLoaderFactory(httpClient: HttpClient) { @@ -278,6 +297,24 @@ export function httpLoaderFactory(httpClient: HttpClient) { ResourceGridComponent, ResourceListComponent, ComparisonComponent, + SearchPanelComponent, + FulltextSearchComponent, + ExpertSearchComponent, + AdvancedSearchComponent, + SearchBooleanValueComponent, + SearchDateValueComponent, + SearchDecimalValueComponent, + SearchIntValueComponent, + SearchLinkValueComponent, + SearchListValueComponent, + SearchDisplayListComponent, + SearchTextValueComponent, + SearchUriValueComponent, + SpecifyPropertyValueComponent, + ResourceAndPropertySelectionComponent, + SearchSelectPropertyComponent, + SearchSelectResourceClassComponent, + SearchSelectOntologyComponent, ], imports: [ AppRoutingModule, diff --git a/src/app/workspace/results/list-view/list-view.component.spec.ts b/src/app/workspace/results/list-view/list-view.component.spec.ts index f836fb4cb8..58b621fa5d 100644 --- a/src/app/workspace/results/list-view/list-view.component.spec.ts +++ b/src/app/workspace/results/list-view/list-view.component.spec.ts @@ -7,7 +7,7 @@ import { MatSnackBarModule } from '@angular/material/snack-bar'; import { CountQueryResponse, IFulltextSearchParams, MockResource, ReadResourceSequence, SearchEndpointV2 } from '@dasch-swiss/dsp-js'; import { of } from 'rxjs'; import { DspApiConnectionToken } from 'src/app/main/declarations/dsp-api-tokens'; -import { AdvancedSearchParams, AdvancedSearchParamsService } from '../../../search/services/advanced-search-params.service'; +import { AdvancedSearchParams, AdvancedSearchParamsService } from '../../search/services/advanced-search-params.service'; import { ListViewComponent, SearchParams } from './list-view.component'; /** diff --git a/src/app/workspace/results/list-view/list-view.component.ts b/src/app/workspace/results/list-view/list-view.component.ts index 691a93abe6..344ea01070 100644 --- a/src/app/workspace/results/list-view/list-view.component.ts +++ b/src/app/workspace/results/list-view/list-view.component.ts @@ -3,7 +3,7 @@ import { PageEvent } from '@angular/material/paginator'; import { ApiResponseError, CountQueryResponse, IFulltextSearchParams, KnoraApiConnection, ReadResourceSequence } from '@dasch-swiss/dsp-js'; import { DspApiConnectionToken } from 'src/app/main/declarations/dsp-api-tokens'; import { NotificationService } from 'src/app/main/services/notification.service'; -import { AdvancedSearchParamsService } from '../../../search/services/advanced-search-params.service'; +import { AdvancedSearchParamsService } from '../../search/services/advanced-search-params.service'; /** * query: search query. It can be gravserch query or fulltext string query. diff --git a/src/app/workspace/search/advanced-search/advanced-search.component.html b/src/app/workspace/search/advanced-search/advanced-search.component.html new file mode 100644 index 0000000000..5e81a0045b --- /dev/null +++ b/src/app/workspace/search/advanced-search/advanced-search.component.html @@ -0,0 +1,23 @@ +
+ +
+ +
+ + + + +
+ + + +
+ +
+ + diff --git a/src/app/workspace/search/advanced-search/advanced-search.component.scss b/src/app/workspace/search/advanced-search/advanced-search.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/workspace/search/advanced-search/advanced-search.component.spec.ts b/src/app/workspace/search/advanced-search/advanced-search.component.spec.ts new file mode 100644 index 0000000000..8d3c9f3328 --- /dev/null +++ b/src/app/workspace/search/advanced-search/advanced-search.component.spec.ts @@ -0,0 +1,179 @@ +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { MatIconModule } from '@angular/material/icon'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { OntologiesEndpointV2, OntologiesMetadata, OntologyMetadata } from '@dasch-swiss/dsp-js'; +import { of } from 'rxjs'; +import { DspApiConnectionToken } from 'src/app/main/declarations/dsp-api-tokens'; +import { AdvancedSearchComponent } from './advanced-search.component'; + +/** + * test component to simulate select ontology component. + */ +@Component({ + selector: 'app-search-select-ontology', + template: '' +}) +class TestSearchSelectOntologyComponent implements OnInit { + + @Input() formGroup: FormGroup; + + @Input() ontologiesMetadata: OntologiesMetadata; + + @Output() ontologySelected = new EventEmitter(); + + ngOnInit() { + + } + +} + +/** + * test component to simulate select resource class and property component. + */ +@Component({ + selector: 'app-resource-and-property-selection', + template: '' +}) +class TestSelectResourceClassAndPropertyComponent { + + @Input() formGroup: FormGroup; + + @Input() activeOntology: string; + + @Input() resClassRestriction?: string; + + @Input() topLevel: boolean; + +} + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostComponent implements OnInit { + + @ViewChild('advSearch') advancedSearch: AdvancedSearchComponent; + + ngOnInit() { + } + +} + +describe('AdvancedSearchComponent', () => { + let testHostComponent: TestHostComponent; + let testHostFixture: ComponentFixture; + + let loader: HarnessLoader; + + beforeEach(waitForAsync(() => { + + const dspConnSpy = { + v2: { + onto: jasmine.createSpyObj('onto', ['getOntologiesMetadata']) + } + }; + + TestBed.configureTestingModule({ + declarations: [ + AdvancedSearchComponent, + TestHostComponent, + TestSearchSelectOntologyComponent, + TestSelectResourceClassAndPropertyComponent + ], + imports: [ + ReactiveFormsModule, + BrowserAnimationsModule, + MatIconModule, + MatSnackBarModule + ], + providers: [ + { + provide: DspApiConnectionToken, + useValue: dspConnSpy + } + ] + }) + .compileComponents(); + })); + + describe('Ontology with resources', () => { + beforeEach(() => { + + const dspConnSpy = TestBed.inject(DspApiConnectionToken); + + (dspConnSpy.v2.onto as jasmine.SpyObj).getOntologiesMetadata.and.callFake( + () => { + + const ontoMetadata = new OntologiesMetadata(); + + const anythingOnto = new OntologyMetadata(); + anythingOnto.id = 'anyid'; + anythingOnto.label = 'anythingOnto'; + + ontoMetadata.ontologies = [anythingOnto]; + + return of(ontoMetadata); + } + ); + + testHostFixture = TestBed.createComponent(TestHostComponent); + testHostComponent = testHostFixture.componentInstance; + + loader = TestbedHarnessEnvironment.loader(testHostFixture); + + testHostFixture.detectChanges(); + }); + + it('should create', () => { + + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.advancedSearch).toBeTruthy(); + + }); + + it('should get ontologies metadata on init', () => { + + const dspConnSpy = TestBed.inject(DspApiConnectionToken); + + expect(testHostComponent.advancedSearch.ontologiesMetadata).toBeDefined(); + expect(testHostComponent.advancedSearch.ontologiesMetadata.ontologies.length).toEqual(1); + + const hostCompDe = testHostFixture.debugElement; + const selectOntoComp = hostCompDe.query(By.directive(TestSearchSelectOntologyComponent)); + + expect((selectOntoComp.componentInstance as TestSearchSelectOntologyComponent).ontologiesMetadata).toBeDefined(); + expect((selectOntoComp.componentInstance as TestSearchSelectOntologyComponent).ontologiesMetadata.ontologies.length).toEqual(1); + + expect(dspConnSpy.v2.onto.getOntologiesMetadata).toHaveBeenCalledTimes(1); + + expect((selectOntoComp.componentInstance as TestSearchSelectOntologyComponent).formGroup).toBeDefined(); + + }); + + it('should set the active ontology when an ontology is selected', () => { + + const hostCompDe = testHostFixture.debugElement; + const selectOntoComp = hostCompDe.query(By.directive(TestSearchSelectOntologyComponent)); + + (selectOntoComp.componentInstance as TestSearchSelectOntologyComponent).ontologySelected.emit('http://0.0.0.0:3333/ontology/0001/anything/v2'); + + testHostFixture.detectChanges(); + + expect(testHostComponent.advancedSearch.activeOntology).toEqual('http://0.0.0.0:3333/ontology/0001/anything/v2'); + + expect(testHostComponent.advancedSearch.resourceAndPropertySelection.activeOntology).toEqual('http://0.0.0.0:3333/ontology/0001/anything/v2'); + + }); + + }); + +}); diff --git a/src/app/workspace/search/advanced-search/advanced-search.component.ts b/src/app/workspace/search/advanced-search/advanced-search.component.ts new file mode 100644 index 0000000000..cd4ea943bf --- /dev/null +++ b/src/app/workspace/search/advanced-search/advanced-search.component.ts @@ -0,0 +1,175 @@ +import { + AfterViewChecked, + Component, + EventEmitter, + Inject, + Input, + OnDestroy, + OnInit, + Output, + ViewChild +} from '@angular/core'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { ApiResponseError, Constants, KnoraApiConnection, OntologiesMetadata } from '@dasch-swiss/dsp-js'; +import { Subscription } from 'rxjs'; +import { DspApiConnectionToken } from 'src/app/main/declarations/dsp-api-tokens'; +import { NotificationService } from 'src/app/main/services/notification.service'; +import { SearchParams } from '../../results/list-view/list-view.component'; +import { GravsearchGenerationService } from '../services/gravsearch-generation.service'; +import { PropertyWithValue } from './resource-and-property-selection/search-select-property/specify-property-value/operator'; +import { ResourceAndPropertySelectionComponent } from './resource-and-property-selection/resource-and-property-selection.component'; + +@Component({ + selector: 'app-advanced-search', + templateUrl: './advanced-search.component.html', + styleUrls: ['./advanced-search.component.scss'] +}) +export class AdvancedSearchComponent implements OnInit, OnDestroy, AfterViewChecked { + + // reference to the component that controls the resource class selection + @ViewChild('resAndPropSel') resourceAndPropertySelection: ResourceAndPropertySelectionComponent; + + /** + * filter ontologies by specified project IRI + * + * @param limitToProject + */ + @Input() limitToProject?: string; + + /** + * the data event emitter of type SearchParams + * + * @param search + */ + @Output() search = new EventEmitter(); + + ontologiesMetadata: OntologiesMetadata; + + // formGroup (used as parent for child components) + form: FormGroup; + + // form validation status + formValid = false; + + activeOntology: string; + + formChangesSubscription: Subscription; + + errorMessage: ApiResponseError; + + constructor( + @Inject(FormBuilder) private _fb: FormBuilder, + @Inject(DspApiConnectionToken) private _dspApiConnection: KnoraApiConnection, + private _notification: NotificationService, + private _gravsearchGenerationService: GravsearchGenerationService) { + } + + ngOnInit() { + + // parent form is empty, it gets passed to the child components + this.form = this._fb.group({}); + + // initialize ontologies to be used for the ontologies selection in the search form + this.initializeOntologies(); + } + + ngAfterViewChecked() { + // if form status changes, re-run validation + this.formChangesSubscription = this.form.statusChanges.subscribe((data) => { + this.formValid = this._validateForm(); + }); + } + + ngOnDestroy() { + if (this.formChangesSubscription !== undefined) { + this.formChangesSubscription.unsubscribe(); + } + } + + /** + * @ignore + * Gets all available ontologies for the search form. + * @returns void + */ + initializeOntologies(): void { + + if (this.limitToProject) { + this._dspApiConnection.v2.onto.getOntologiesByProjectIri(this.limitToProject).subscribe( + (response: OntologiesMetadata) => { + // filter out system ontologies + response.ontologies = response.ontologies.filter(onto => onto.attachedToProject !== Constants.SystemProjectIRI); + + this.ontologiesMetadata = response; + }, + (error: ApiResponseError) => { + this._notification.openSnackBar(error); + this.errorMessage = error; + }); + } else { + this._dspApiConnection.v2.onto.getOntologiesMetadata().subscribe( + (response: OntologiesMetadata) => { + // filter out system ontologies + response.ontologies = response.ontologies.filter(onto => onto.attachedToProject !== Constants.SystemProjectIRI); + + this.ontologiesMetadata = response; + }, + (error: ApiResponseError) => { + this._notification.openSnackBar(error); + this.errorMessage = error; + }); + } + } + + setActiveOntology(ontologyIri: string) { + this.activeOntology = ontologyIri; + } + + submit() { + + if (!this.formValid) { + return; // check that form is valid + } + + const resClassOption = this.resourceAndPropertySelection.resourceClassComponent.selectedResourceClassIri; + + let resClass; + + if (resClassOption !== false) { + resClass = resClassOption; + } + + const properties: PropertyWithValue[] = this.resourceAndPropertySelection.propertyComponents.map( + (propComp) => propComp.getPropertySelectedWithValue() + ); + + const gravsearch = this._gravsearchGenerationService.createGravsearchQuery(properties, resClass); + + if (gravsearch) { + // emit query + this.search.emit({ + query: gravsearch, + mode: 'gravsearch' + }); + } + } + + /** + * @ignore + * Validates form and returns its status (boolean). + */ + private _validateForm(): boolean { + + if (this.resourceAndPropertySelection === undefined + || this.resourceAndPropertySelection.resourceClassComponent === undefined + || this.resourceAndPropertySelection.propertyComponents === undefined) { + return false; + } + + // check that either a resource class is selected or at least one property is specified + return this.form.valid && + (this.resourceAndPropertySelection.propertyComponents.length > 0 + || this.resourceAndPropertySelection.resourceClassComponent.selectedResourceClassIri !== false + ); + } + +} diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/resource-and-property-selection.component.html b/src/app/workspace/search/advanced-search/resource-and-property-selection/resource-and-property-selection.component.html new file mode 100644 index 0000000000..fd01a6227f --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/resource-and-property-selection.component.html @@ -0,0 +1,33 @@ +
+ +
+ + +
+ +
+
+ + + +
+
+ +
+ + + +
+ +
diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/resource-and-property-selection.component.scss b/src/app/workspace/search/advanced-search/resource-and-property-selection/resource-and-property-selection.component.scss new file mode 100644 index 0000000000..d7bdb4c4de --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/resource-and-property-selection.component.scss @@ -0,0 +1,7 @@ +.select-property { + margin-left: 16px; + + .property-button { + margin: 0 12px 64px 0; + } +} diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/resource-and-property-selection.component.spec.ts b/src/app/workspace/search/advanced-search/resource-and-property-selection/resource-and-property-selection.component.spec.ts new file mode 100644 index 0000000000..c67e404599 --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/resource-and-property-selection.component.spec.ts @@ -0,0 +1,331 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ResourceAndPropertySelectionComponent } from './resource-and-property-selection.component'; +import { HarnessLoader } from '@angular/cdk/testing'; +import { Component, EventEmitter, Inject, Input, OnInit, Output, ViewChild } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { OntologyCache } from '@dasch-swiss/dsp-js/src/cache/ontology-cache/OntologyCache'; +import { MockOntology, ReadOntology, ResourceClassDefinition, ResourcePropertyDefinition } from '@dasch-swiss/dsp-js'; +import { of } from 'rxjs'; +import { MatIconModule } from '@angular/material/icon'; +import { By } from '@angular/platform-browser'; +import { MatButtonHarness } from '@angular/material/button/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { DspApiConnectionToken } from 'src/app/main/declarations/dsp-api-tokens'; + +/** + * test host component to simulate select resource class component. + */ +@Component({ + selector: '', + template: '' +}) +class TestSearchSelectResourceClassComponent { + + @Input() formGroup: FormGroup; + + @Input() resourceClassDefinitions: ResourceClassDefinition[]; + + @Output() resourceClassSelected = new EventEmitter(); + +} + +/** + * test host component to simulate select property component. + */ +@Component({ + selector: '', + template: '' +}) +class TestSearchSelectPropertyComponent { + + // parent FormGroup + @Input() formGroup: FormGroup; + + // index of the given property (unique) + @Input() index: number; + + // properties that can be selected from + @Input() properties: ResourcePropertyDefinition[]; + + @Input() activeResourceClass: ResourceClassDefinition; + + @Input() topLevel: boolean; + +} + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + + ` +}) +class TestHostComponent implements OnInit { + + @ViewChild('resClassAndProp') resourceClassAndPropertySelection: ResourceAndPropertySelectionComponent; + + form: FormGroup; + + activeOntology = 'http://0.0.0.0:3333/ontology/0001/anything/v2'; + + restrictByResourceClass?: string; + + constructor(@Inject(FormBuilder) private _fb: FormBuilder) { + } + + ngOnInit() { + this.form = this._fb.group({}); + } + +} + +describe('ResourceAndPropertySelectionComponent', () => { + let testHostComponent: TestHostComponent; + let testHostFixture: ComponentFixture; + + let loader: HarnessLoader; + + beforeEach(async () => { + + const dspConnSpy = { + v2: { + ontologyCache: jasmine.createSpyObj('ontologyCache', ['getOntology', 'getResourceClassDefinition']) + } + }; + + await TestBed.configureTestingModule({ + imports: [ + ReactiveFormsModule, + MatIconModule + ], + declarations: [ + ResourceAndPropertySelectionComponent, + TestHostComponent, + TestSearchSelectResourceClassComponent, + TestSearchSelectPropertyComponent + ], + providers: [ + { + provide: DspApiConnectionToken, + useValue: dspConnSpy + } + ] + }) + .compileComponents(); + }); + + beforeEach(() => { + + const dspConnSpy = TestBed.inject(DspApiConnectionToken); + + (dspConnSpy.v2.ontologyCache as jasmine.SpyObj).getOntology.and.callFake( + (ontoIri: string) => { + + const anythingOnto = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2'); + const knoraApiOnto = MockOntology.mockReadOntology('http://api.knora.org/ontology/knora-api/v2'); + + const ontoMap: Map = new Map(); + + ontoMap.set('http://api.knora.org/ontology/knora-api/v2', knoraApiOnto); + ontoMap.set('http://0.0.0.0:3333/ontology/0001/anything/v2', anythingOnto); + + return of(ontoMap); + } + ); + + testHostFixture = TestBed.createComponent(TestHostComponent); + testHostComponent = testHostFixture.componentInstance; + + loader = TestbedHarnessEnvironment.loader(testHostFixture); + + testHostFixture.detectChanges(); + }); + + it('should create', () => { + expect(testHostComponent).toBeTruthy(); + }); + + it('should request the active ontology', () => { + + const dspConnSpy = TestBed.inject(DspApiConnectionToken); + + const hostCompDe = testHostFixture.debugElement; + + expect(testHostComponent.resourceClassAndPropertySelection.activeOntology).toEqual('http://0.0.0.0:3333/ontology/0001/anything/v2'); + + expect(testHostComponent.resourceClassAndPropertySelection.activeResourceClass).toEqual(undefined); + expect(testHostComponent.resourceClassAndPropertySelection.resourceClasses.length).toEqual(8); + expect(Object.keys(testHostComponent.resourceClassAndPropertySelection.properties).length).toEqual(28); + + const selectResClassComp = hostCompDe.query(By.directive(TestSearchSelectResourceClassComponent)); + expect((selectResClassComp.componentInstance as TestSearchSelectResourceClassComponent).resourceClassDefinitions.length).toEqual(8); + + expect(dspConnSpy.v2.ontologyCache.getOntology).toHaveBeenCalledTimes(1); + expect(dspConnSpy.v2.ontologyCache.getOntology).toHaveBeenCalledWith('http://0.0.0.0:3333/ontology/0001/anything/v2'); + + }); + + it('should react when a resource class is selected', async () => { + + const dspConnSpy = TestBed.inject(DspApiConnectionToken); + + (dspConnSpy.v2.ontologyCache as jasmine.SpyObj).getResourceClassDefinition.and.callFake( + (resClassIri: string) => of(MockOntology.mockIResourceClassAndPropertyDefinitions('http://0.0.0.0:3333/ontology/0001/anything/v2#Thing')) + ); + + const anythingOnto = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2'); + + // get resource class defs + testHostComponent.resourceClassAndPropertySelection.resourceClasses = anythingOnto.getClassDefinitionsByType(ResourceClassDefinition); + + const resProps = anythingOnto.getPropertyDefinitionsByType(ResourcePropertyDefinition); + + testHostComponent.resourceClassAndPropertySelection.properties = resProps; + + testHostFixture.detectChanges(); + + const hostCompDe = testHostFixture.debugElement; + const selectResClassComp = hostCompDe.query(By.directive(TestSearchSelectResourceClassComponent)); + + (selectResClassComp.componentInstance as TestSearchSelectResourceClassComponent).resourceClassSelected.emit('http://0.0.0.0:3333/ontology/0001/anything/v2#Thing'); + + testHostFixture.detectChanges(); + + expect(testHostComponent.resourceClassAndPropertySelection.activeResourceClass) + .toEqual(MockOntology.mockIResourceClassAndPropertyDefinitions('http://0.0.0.0:3333/ontology/0001/anything/v2#Thing').classes['http://0.0.0.0:3333/ontology/0001/anything/v2#Thing']); + expect(Object.keys(testHostComponent.resourceClassAndPropertySelection.properties).length).toEqual(25); + + expect(dspConnSpy.v2.ontologyCache.getResourceClassDefinition).toHaveBeenCalledTimes(1); + expect(dspConnSpy.v2.ontologyCache.getResourceClassDefinition).toHaveBeenCalledWith('http://0.0.0.0:3333/ontology/0001/anything/v2#Thing'); + + const addPropButton = await loader.getHarness(MatButtonHarness.with({ selector: '.add-property-button' })); + + await addPropButton.click(); + + const selectPropComp = hostCompDe.query(By.directive(TestSearchSelectPropertyComponent)); + + expect((selectPropComp.componentInstance as TestSearchSelectPropertyComponent).activeResourceClass) + .toEqual(MockOntology.mockIResourceClassAndPropertyDefinitions('http://0.0.0.0:3333/ontology/0001/anything/v2#Thing') + .classes['http://0.0.0.0:3333/ontology/0001/anything/v2#Thing']); + + expect((selectPropComp.componentInstance as TestSearchSelectPropertyComponent).index).toEqual(0); + expect(Object.keys((selectPropComp.componentInstance as TestSearchSelectPropertyComponent).properties).length).toEqual(25); + + }); + + it('should disable add property button on init', async () => { + + const addPropButton = await loader.getHarness(MatButtonHarness.with({ selector: '.add-property-button' })); + + expect(await addPropButton.isDisabled()).toBe(false); + }); + + it('should disable remove property button on init', async () => { + + const rmPropButton = await loader.getHarness(MatButtonHarness.with({ selector: '.remove-property-button' })); + + expect(await rmPropButton.isDisabled()).toBe(true); + + }); + + it('should display a property selection when the add property button has been clicked', async () => { + + const anythingOnto = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2'); + + // get resource class defs + testHostComponent.resourceClassAndPropertySelection.resourceClasses = anythingOnto.getClassDefinitionsByType(ResourceClassDefinition); + + const resProps = anythingOnto.getPropertyDefinitionsByType(ResourcePropertyDefinition); + + testHostComponent.resourceClassAndPropertySelection.properties = resProps; + + testHostFixture.detectChanges(); + + expect(testHostComponent.resourceClassAndPropertySelection.activeProperties.length).toEqual(0); + + const addPropButton = await loader.getHarness(MatButtonHarness.with({ selector: '.add-property-button' })); + + expect(await addPropButton.isDisabled()).toBe(false); + + await addPropButton.click(); + + expect(testHostComponent.resourceClassAndPropertySelection.activeProperties.length).toEqual(1); + + const hostCompDe = testHostFixture.debugElement; + const selectPropComp = hostCompDe.query(By.directive(TestSearchSelectPropertyComponent)); + + expect((selectPropComp.componentInstance as TestSearchSelectPropertyComponent).activeResourceClass).toEqual(undefined); + expect((selectPropComp.componentInstance as TestSearchSelectPropertyComponent).index).toEqual(0); + expect((selectPropComp.componentInstance as TestSearchSelectPropertyComponent).properties).toEqual(resProps); + + const rmPropButton = await loader.getHarness(MatButtonHarness.with({ selector: '.remove-property-button' })); + + expect(await rmPropButton.isDisabled()).toBe(false); + }); + + it('should add to and remove from active properties array when property buttons are clicked', async () => { + + const anythingOnto = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2'); + + // get resource class defs + testHostComponent.resourceClassAndPropertySelection.resourceClasses = anythingOnto.getClassDefinitionsByType(ResourceClassDefinition); + + const resProps = anythingOnto.getPropertyDefinitionsByType(ResourcePropertyDefinition); + + testHostComponent.resourceClassAndPropertySelection.properties = resProps; + + testHostFixture.detectChanges(); + + const addPropButton = await loader.getHarness(MatButtonHarness.with({ selector: '.add-property-button' })); + + const rmPropButton = await loader.getHarness(MatButtonHarness.with({ selector: '.remove-property-button' })); + + expect(testHostComponent.resourceClassAndPropertySelection.activeProperties.length).toEqual(0); + + await addPropButton.click(); + + expect(testHostComponent.resourceClassAndPropertySelection.activeProperties.length).toEqual(1); + + await addPropButton.click(); + + expect(testHostComponent.resourceClassAndPropertySelection.activeProperties.length).toEqual(2); + + await rmPropButton.click(); + + expect(testHostComponent.resourceClassAndPropertySelection.activeProperties.length).toEqual(1); + + await rmPropButton.click(); + + expect(testHostComponent.resourceClassAndPropertySelection.activeProperties.length).toEqual(0); + }); + + + it('should add at max four property selections', async () => { + + // simulate state after anything onto selection + testHostComponent.resourceClassAndPropertySelection.activeOntology = 'http://0.0.0.0:3333/ontology/0001/anything/v2'; + + const anythingOnto = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2'); + + // get resource class defs + testHostComponent.resourceClassAndPropertySelection.resourceClasses = anythingOnto.getClassDefinitionsByType(ResourceClassDefinition); + + const resProps = anythingOnto.getPropertyDefinitionsByType(ResourcePropertyDefinition); + + testHostComponent.resourceClassAndPropertySelection.properties = resProps; + + testHostComponent.resourceClassAndPropertySelection.activeProperties = [true, true, true, true]; + + testHostFixture.detectChanges(); + + const addPropButton = await loader.getHarness(MatButtonHarness.with({ selector: '.add-property-button' })); + + expect(await addPropButton.isDisabled()).toEqual(true); + + }); + +}); diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/resource-and-property-selection.component.ts b/src/app/workspace/search/advanced-search/resource-and-property-selection/resource-and-property-selection.component.ts new file mode 100644 index 0000000000..f57630ad5b --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/resource-and-property-selection.component.ts @@ -0,0 +1,151 @@ +import { + Component, + Inject, + Input, + OnChanges, + OnInit, + QueryList, + SimpleChanges, + ViewChild, + ViewChildren +} from '@angular/core'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { + KnoraApiConnection, + ReadOntology, ResourceClassAndPropertyDefinitions, + ResourceClassDefinition, + ResourcePropertyDefinition +} from '@dasch-swiss/dsp-js'; +import { DspApiConnectionToken } from 'src/app/main/declarations/dsp-api-tokens'; +import { SearchSelectPropertyComponent } from './search-select-property/search-select-property.component'; +import { SearchSelectResourceClassComponent } from './search-select-resource-class/search-select-resource-class.component'; + + +@Component({ + selector: 'app-resource-and-property-selection', + templateUrl: './resource-and-property-selection.component.html', + styleUrls: ['./resource-and-property-selection.component.scss'] +}) +export class ResourceAndPropertySelectionComponent implements OnInit, OnChanges { + + // reference to the component that controls the resource class selection + @ViewChild('resourceClass') resourceClassComponent: SearchSelectResourceClassComponent; + + // reference to the component controlling the property selection + @ViewChildren('property') propertyComponents: QueryList; + + @Input() formGroup: FormGroup; + + @Input() activeOntology: string; + + @Input() resourceClassRestriction?: string; + + @Input() topLevel; + + form: FormGroup; + + activeResourceClass: ResourceClassDefinition; + + activeProperties: boolean[] = []; + + resourceClasses: ResourceClassDefinition[]; + + properties: ResourcePropertyDefinition[]; + + constructor( + @Inject(FormBuilder) private _fb: FormBuilder, + @Inject(DspApiConnectionToken) private _dspApiConnection: KnoraApiConnection) { + } + + ngOnInit(): void { + + this.form = this._fb.group({}); + + // add form to the parent form group + this.formGroup.addControl('resourceAndPropertySelection', this.form); + } + + ngOnChanges(changes: SimpleChanges) { + this.getResourceClassesAndPropertiesForOntology(this.activeOntology); + } + + getResourceClassesAndPropertiesForOntology(ontologyIri: string) { + + // reset active resource class definition + this.activeResourceClass = undefined; + + // reset specified properties + this.activeProperties = []; + + this._dspApiConnection.v2.ontologyCache.getOntology(ontologyIri).subscribe( + (onto: Map) => { + + const resClasses = onto.get(ontologyIri).getClassDefinitionsByType(ResourceClassDefinition); + + if (this.resourceClassRestriction !== undefined) { + this.resourceClasses = resClasses.filter( + (resClassDef: ResourceClassDefinition) => resClassDef.id === this.resourceClassRestriction + ); + } else { + this.resourceClasses = resClasses; + } + + this.properties = onto.get(ontologyIri).getPropertyDefinitionsByType(ResourcePropertyDefinition); + }, + error => { + console.error(error); + } + ); + } + + getPropertiesForResourceClass(resourceClassIri: string | null) { + + // reset specified properties + this.activeProperties = []; + + // if the client undoes the selection of a resource class, use the active ontology as a fallback + if (resourceClassIri === null) { + this.getResourceClassesAndPropertiesForOntology(this.activeOntology); + } else { + + this._dspApiConnection.v2.ontologyCache.getResourceClassDefinition(resourceClassIri).subscribe( + (onto: ResourceClassAndPropertyDefinitions) => { + + this.activeResourceClass = onto.classes[resourceClassIri]; + + this.properties = onto.getPropertyDefinitionsByType(ResourcePropertyDefinition); + + } + ); + } + } + + /** + * @ignore + * Add a property to the search form. + * @returns void + */ + addProperty(): void { + this.activeProperties.push(true); + } + + /** + * @ignore + * Remove the last property from the search form. + * @returns void + */ + removeProperty(): void { + this.activeProperties.splice(-1, 1); + } + + /** + * @ignore + * Resets the form (selected resource class and specified properties) preserving the active ontology. + */ + resetForm() { + if (this.activeOntology !== undefined) { + this.getResourceClassesAndPropertiesForOntology(this.activeOntology); + } + } + +} diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/search-select-property.component.html b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/search-select-property.component.html new file mode 100644 index 0000000000..b312b5cd50 --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/search-select-property.component.html @@ -0,0 +1,12 @@ + + + + {{ prop.label }} + + + + + + + diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/search-select-property.component.scss b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/search-select-property.component.scss new file mode 100644 index 0000000000..98ec786bf3 --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/search-select-property.component.scss @@ -0,0 +1,3 @@ +.search-property-field { + margin-right: 8px; +} diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/search-select-property.component.spec.ts b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/search-select-property.component.spec.ts new file mode 100644 index 0000000000..f2d8cb29a1 --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/search-select-property.component.spec.ts @@ -0,0 +1,298 @@ +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Component, Inject, Input, OnInit, ViewChild } from '@angular/core'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { MatSelectModule } from '@angular/material/select'; +import { MatOptionModule } from '@angular/material/core'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { HarnessLoader } from '@angular/cdk/testing'; +import { + Cardinality, + Constants, + MockOntology, + ResourceClassDefinition, + ResourcePropertyDefinition +} from '@dasch-swiss/dsp-js'; +import { MatSelectHarness } from '@angular/material/select/testing'; +import { MatCheckboxHarness } from '@angular/material/checkbox/testing'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { ComparisonOperatorAndValue, Equals, ValueLiteral } from './specify-property-value/operator'; +import { SearchSelectPropertyComponent } from './search-select-property.component'; + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostComponent implements OnInit { + + @ViewChild('selectProp') selectProperty: SearchSelectPropertyComponent; + + form: FormGroup; + + propertyDefs: ResourcePropertyDefinition[]; + + activeResourceClass: ResourceClassDefinition; + + topLevel: boolean; + + constructor(@Inject(FormBuilder) private _fb: FormBuilder) { + } + + ngOnInit() { + this.form = this._fb.group({}); + + const resProps = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2').getPropertyDefinitionsByType(ResourcePropertyDefinition); + + this.propertyDefs = resProps; + + this.topLevel = true; + } + +} + +/** + * test component to simulate specify property value component. + */ +@Component({ + selector: 'app-specify-property-value', + template: '' +}) +class TestSpecifyPropertyValueComponent implements OnInit { + + @Input() formGroup: FormGroup; + + @Input() property: ResourcePropertyDefinition; + + @Input() topLevel: boolean; + + getComparisonOperatorAndValueLiteralForProperty(): ComparisonOperatorAndValue { + return new ComparisonOperatorAndValue(new Equals(), new ValueLiteral('1', 'http://www.w3.org/2001/XMLSchema#integer')); + } + + ngOnInit() { + } + +} + +describe('SearchSelectPropertyComponent', () => { + let testHostComponent: TestHostComponent; + let testHostFixture: ComponentFixture; + + let loader: HarnessLoader; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + BrowserAnimationsModule, + ReactiveFormsModule, + MatSelectModule, + MatOptionModule, + MatCheckboxModule + ], + declarations: [ + SearchSelectPropertyComponent, + TestHostComponent, + TestSpecifyPropertyValueComponent + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostComponent); + testHostComponent = testHostFixture.componentInstance; + loader = TestbedHarnessEnvironment.loader(testHostFixture); + + testHostFixture.detectChanges(); + }); + + it('should create', () => { + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.selectProperty).toBeTruthy(); + }); + + it('should initialise the Inputs correctly', () => { + + expect(testHostComponent.selectProperty.formGroup).toBeDefined(); + expect(testHostComponent.selectProperty.index).toEqual(0); + expect(testHostComponent.selectProperty.activeResourceClass).toBeUndefined(); + expect(Object.keys(testHostComponent.selectProperty.properties).length).toEqual(22); + expect(testHostComponent.selectProperty.properties.length).toEqual(22); + }); + + it('should add a new control to the parent form', waitForAsync(() => { + + // the control is added to the form as an async operation + // https://angular.io/guide/testing#async-test-with-async + testHostFixture.whenStable().then( + () => { + expect(testHostComponent.form.contains('property0')).toBe(true); + } + ); + + })); + + it('should init the MatSelect and MatOptions correctly', async () => { + + const select = await loader.getHarness(MatSelectHarness); + const initVal = await select.getValueText(); + + // placeholder + expect(initVal).toEqual('Select Properties'); + + await select.open(); + + const options = await select.getOptions(); + + expect(options.length).toEqual(22); + + }); + + it('should set the active property', async () => { + + expect(testHostComponent.selectProperty.propertySelected).toBeUndefined(); + + const select = await loader.getHarness(MatSelectHarness); + await select.open(); + + const options = await select.getOptions(); + + await options[0].click(); + + expect(testHostComponent.selectProperty.propertySelected.id).toEqual('http://0.0.0.0:3333/ontology/0001/anything/v2#hasBlueThing'); + + expect(testHostComponent.selectProperty.specifyPropertyValue.property).toBeDefined(); + expect(testHostComponent.selectProperty.specifyPropertyValue.property.id).toEqual('http://0.0.0.0:3333/ontology/0001/anything/v2#hasBlueThing'); + + }); + + it('should not show the sort checkbox when no property is selected', async () => { + + const checkbox = await loader.getAllHarnesses(MatCheckboxHarness); + + expect(checkbox.length).toEqual(0); + + }); + + it('should show the sort checkbox when a property with cardinality 1 is selected for the top level resource', async () => { + + const select = await loader.getHarness(MatSelectHarness); + await select.open(); + + const options = await select.getOptions(); + expect(await options[4].getText()).toEqual('Date'); + + await options[4].click(); + + const resClass = new ResourceClassDefinition(); + resClass.propertiesList = [{ + propertyIndex: 'http://0.0.0.0:3333/ontology/0001/anything/v2#hasDate', + cardinality: Cardinality._1, + isInherited: true + }]; + + testHostComponent.activeResourceClass = resClass; + + testHostFixture.detectChanges(); + + const checkbox = await loader.getAllHarnesses(MatCheckboxHarness); + + expect(checkbox.length).toEqual(1); + + }); + + it('should not show the sort checkbox when a property with cardinality 1 is selected for a linked resource', async () => { + + testHostComponent.topLevel = false; + + const select = await loader.getHarness(MatSelectHarness); + await select.open(); + + const options = await select.getOptions(); + expect(await options[4].getText()).toEqual('Date'); + + await options[4].click(); + + const resClass = new ResourceClassDefinition(); + resClass.propertiesList = [{ + propertyIndex: 'http://0.0.0.0:3333/ontology/0001/anything/v2#hasDate', + cardinality: Cardinality._1, + isInherited: true + }]; + + testHostComponent.activeResourceClass = resClass; + + testHostFixture.detectChanges(); + + const checkbox = await loader.getAllHarnesses(MatCheckboxHarness); + + expect(checkbox.length).toEqual(0); + + }); + + it('should get the specified value for the selected property', async () => { + + const select = await loader.getHarness(MatSelectHarness); + await select.open(); + + const options = await select.getOptions(); + + expect(await options[11].getText()).toEqual('Integer'); + + await options[11].click(); + + const propWithVal = testHostComponent.selectProperty.getPropertySelectedWithValue(); + + expect(propWithVal.property.id).toEqual('http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger'); + + expect(propWithVal.valueLiteral.value).toEqual(new ValueLiteral('1', Constants.XsdInteger)); + expect(propWithVal.valueLiteral.comparisonOperator).toEqual(new Equals()); + + expect(propWithVal.isSortCriterion).toBe(false); + + }); + + it('should reinitialise the properties', async () => { + + expect(testHostComponent.selectProperty.propertySelected).toBeUndefined(); + + const select = await loader.getHarness(MatSelectHarness); + await select.open(); + + const options = await select.getOptions(); + + await options[0].click(); + + expect(testHostComponent.selectProperty.propertySelected.id).toEqual('http://0.0.0.0:3333/ontology/0001/anything/v2#hasBlueThing'); + + testHostComponent.propertyDefs + = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2').getPropertyDefinitionsByType(ResourcePropertyDefinition); + + testHostFixture.detectChanges(); + + expect(testHostComponent.selectProperty.propertySelected).toBeUndefined(); + }); + + it('should remove the control from the parent form and unsubscribe from value changes when destroyed', waitForAsync(() => { + + expect(testHostComponent.selectProperty.propertyChangesSubscription.closed).toBe(false); + + // tODO: find out why testHostFixture.destroy() does not trigger the component's ngOnDestroy + testHostComponent.selectProperty.ngOnDestroy(); + + // the control is added to the form as an async operation + // https://angular.io/guide/testing#async-test-with-async + testHostFixture.whenStable().then( + () => { + expect(testHostComponent.form.contains('property0')).toBe(false); + expect(testHostComponent.selectProperty.propertyChangesSubscription.closed).toBe(true); + } + ); + + })); +}); diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/search-select-property.component.ts b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/search-select-property.component.ts new file mode 100644 index 0000000000..190e073646 --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/search-select-property.component.ts @@ -0,0 +1,151 @@ +import { Component, Inject, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { Cardinality, IHasProperty, ResourceClassDefinition, ResourcePropertyDefinition } from '@dasch-swiss/dsp-js'; +import { Subscription } from 'rxjs'; +import { SortingService } from 'src/app/main/services/sorting.service'; +import { ComparisonOperatorAndValue, PropertyWithValue } from './specify-property-value/operator'; +import { SpecifyPropertyValueComponent } from './specify-property-value/specify-property-value.component'; + +// https://stackoverflow.com/questions/45661010/dynamic-nested-reactive-form-expressionchangedafterithasbeencheckederror +const resolvedPromise = Promise.resolve(null); + +@Component({ + selector: 'app-search-select-property', + templateUrl: './search-select-property.component.html', + styleUrls: ['./search-select-property.component.scss'] +}) +export class SearchSelectPropertyComponent implements OnInit, OnDestroy { + // reference to child component: combination of comparison operator and value for chosen property + @ViewChild('specifyPropertyValue', { static: false }) specifyPropertyValue: SpecifyPropertyValueComponent; + + // parent FormGroup + @Input() formGroup: FormGroup; + + // index of the given property (unique) + @Input() index: number; + + @Input() topLevel: boolean; + + // setter method for properties when being updated by parent component + @Input() + set properties(value: ResourcePropertyDefinition[]) { + this.propertySelected = undefined; // reset selected property (overwriting any previous selection) + this._properties = this._sortingService.keySortByAlphabetical(value, 'label') + .filter(propDef => propDef.isEditable && !propDef.isLinkValueProperty); + } + + get properties() { + return this._properties; + } + + // setter method for selected resource class + @Input() + set activeResourceClass(value: ResourceClassDefinition) { + this._activeResourceClass = value; + } + + // represents the currently selected property + propertySelected: ResourcePropertyDefinition; + + form: FormGroup; + + // unique name for this property to be used in the parent FormGroup + propIndex: string; + + propertyChangesSubscription: Subscription; + + // properties that can be selected from + private _properties: ResourcePropertyDefinition[]; + + private _activeResourceClass?: ResourceClassDefinition; + + constructor( + @Inject(FormBuilder) private _fb: FormBuilder, + private _sortingService: SortingService) { + } + + ngOnInit() { + + // build a form for the property selection + this.form = this._fb.group({ + property: [null, Validators.required], + isSortCriterion: [false, Validators.required] + }); + + // update the selected property + this.propertyChangesSubscription = this.form.valueChanges.subscribe((data) => { + // data.property points to a ResourcePropertyDefinition + this.propertySelected = data.property; + }); + + resolvedPromise.then(() => { + this.propIndex = 'property' + this.index; + + // add form to the parent form group + this.formGroup.addControl(this.propIndex, this.form); + }); + + } + + ngOnDestroy() { + + // remove form from the parent form group + resolvedPromise.then(() => { + if (this.propertyChangesSubscription !== undefined) { + this.propertyChangesSubscription.unsubscribe(); + } + + this.formGroup.removeControl(this.propIndex); + }); + } + + /** + * indicates if property can be used as a sort criterion. + * Property has to have cardinality or max cardinality 1 for the chosen resource class. + * + * We cannot sort by properties whose cardinality is greater than 1. + * Return boolean + */ + sortCriterion(): boolean { + + // tODO: this method is called from the template. It is called on each change detection cycle. + // tODO: this is acceptable because this method has no side-effects + // tODO: find a better way: evaluate once and store the result in a class member + + // check if a resource class is selected and if the property's cardinality is 1 for the selected resource class + // sort criterion is only available for main resource on top level + if (this.topLevel && this._activeResourceClass !== undefined && this.propertySelected !== undefined && !this.propertySelected.isLinkProperty) { + + const cardinalities: IHasProperty[] = this._activeResourceClass.propertiesList.filter( + (card: IHasProperty) => + // cardinality 1 or max occurrence 1 + card.propertyIndex === this.propertySelected.id + && (card.cardinality === Cardinality._1 || card.cardinality === Cardinality._0_1) + + ); + return cardinalities.length === 1; + } else { + return false; + } + + } + + /** + * returns the selected property with the specified value. + */ + getPropertySelectedWithValue(): PropertyWithValue { + + const propVal: ComparisonOperatorAndValue = this.specifyPropertyValue.getComparisonOperatorAndValueLiteralForProperty(); + + let isSortCriterion = false; + + // only non linking properties can be used for sorting + if (!this.propertySelected.isLinkProperty) { + isSortCriterion = this.form.value.isSortCriterion; + } + + return new PropertyWithValue(this.propertySelected, propVal, isSortCriterion); + + } + +} diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/operator-constants.ts b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/operator-constants.ts new file mode 100644 index 0000000000..fe63c7e631 --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/operator-constants.ts @@ -0,0 +1,33 @@ +import { Constants } from '@dasch-swiss/dsp-js'; + +export class ComparisonOperatorConstants { + + static EqualsComparisonOperator = '='; + static EqualsComparisonLabel = 'is equal to'; + + static NotEqualsComparisonOperator = '!='; + static NotEqualsComparisonLabel = 'is not equal to'; + + static GreaterThanComparisonOperator = '>'; + static GreaterThanComparisonLabel = 'is greater than'; + + static GreaterThanEqualsComparisonOperator = '>='; + static GreaterThanEqualsComparisonLabel = 'is greater than or equal to'; + + static LessThanComparisonOperator = '<'; + static LessThanComparisonLabel = 'is less than'; + + static LessThanEqualsComparisonOperator = '<='; + static LessThanQualsComparisonLabel = 'is less than or equal to'; + + static ExistsComparisonOperator = 'E'; + static ExistsComparisonLabel = 'exists'; + + static LikeComparisonOperator = 'regex'; + static LikeComparisonLabel = 'is like'; + + static MatchComparisonOperator = 'contains'; + static MatchComparisonLabel = 'matches'; + + static MatchFunction = Constants.KnoraApiV2 + Constants.HashDelimiter + 'matchText'; +} diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/operator.ts b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/operator.ts new file mode 100644 index 0000000000..5f5bf17e78 --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/operator.ts @@ -0,0 +1,283 @@ +import { ResourcePropertyDefinition } from '@dasch-swiss/dsp-js'; +import { ComparisonOperatorConstants } from './operator-constants'; + +/** + * an abstract interface representing a comparison operator. + * This interface is implemented for the supported comparison operators. + */ +export interface ComparisonOperator { + + // type of comparison operator + type: string; + + // the label of the comparison operator to be presented to the user. + label: string; + + // returns the class name when called on an instance + getClassName(): string; +} + +export class Equals implements ComparisonOperator { + + type = ComparisonOperatorConstants.EqualsComparisonOperator; + label = ComparisonOperatorConstants.EqualsComparisonLabel; + + constructor() { + } + + getClassName() { + return 'Equals'; + } +} + + +export class NotEquals implements ComparisonOperator { + + type = ComparisonOperatorConstants.NotEqualsComparisonOperator; + label = ComparisonOperatorConstants.NotEqualsComparisonLabel; + + constructor() { + } + + getClassName() { + return 'NotEquals'; + } +} + +export class GreaterThanEquals implements ComparisonOperator { + + type = ComparisonOperatorConstants.GreaterThanEqualsComparisonOperator; + label = ComparisonOperatorConstants.GreaterThanEqualsComparisonLabel; + + constructor() { + } + + getClassName() { + return 'GreaterThanEquals'; + } +} + +export class GreaterThan implements ComparisonOperator { + + type = ComparisonOperatorConstants.GreaterThanComparisonOperator; + label = ComparisonOperatorConstants.GreaterThanComparisonLabel; + + constructor() { + } + + getClassName() { + return 'GreaterThan'; + } +} + +export class LessThan implements ComparisonOperator { + + type = ComparisonOperatorConstants.LessThanComparisonOperator; + label = ComparisonOperatorConstants.LessThanComparisonLabel; + + constructor() { + } + + getClassName() { + return 'LessThan'; + } +} + +export class LessThanEquals implements ComparisonOperator { + + type = ComparisonOperatorConstants.LessThanEqualsComparisonOperator; + label = ComparisonOperatorConstants.LessThanQualsComparisonLabel; + + constructor() { + } + + getClassName() { + return 'LessThanEquals'; + } +} + + +export class Exists implements ComparisonOperator { + + type = ComparisonOperatorConstants.ExistsComparisonOperator; + label = ComparisonOperatorConstants.ExistsComparisonLabel; + + constructor() { + } + + getClassName() { + return 'Exists'; + } +} + +export class Like implements ComparisonOperator { + + type = ComparisonOperatorConstants.LikeComparisonOperator; + label = ComparisonOperatorConstants.LikeComparisonLabel; + + constructor() { + } + + getClassName() { + return 'Like'; + } + +} + +export class Match implements ComparisonOperator { + + type = ComparisonOperatorConstants.MatchComparisonOperator; + label = ComparisonOperatorConstants.MatchComparisonLabel; + + constructor() { + } + + getClassName() { + return 'Match'; + } + +} + +/** + * combination of a comparison operator and a value literal or an IRI. + * In case the comparison operator is 'Exists', no value is given. + */ +export class ComparisonOperatorAndValue { + + constructor(readonly comparisonOperator: ComparisonOperator, readonly value?: Value) { + } +} + +/** + * an abstract interface representing a value: an IRI or a literal. + */ +export interface Value { + + /** + * turns the value into a SPARQL string representation. + * + */ + toSparql(): string; + +} + +/** + * represents a property's value as a literal with the indication of its type. + */ +export class ValueLiteral implements Value { + + /** + * constructs a [ValueLiteral]. + * + * @param value the literal representation of the value. + * @param type the type of the value (making use of xsd). + */ + constructor( + public readonly value: string, + public readonly type: string) { + } + + + /** + * creates a type annotated value literal to be used in a SPARQL query. + * + */ + public toSparql(): string { + return `"${this.value}"^^<${this.type}>`; + } + +} + +/** + * represents an IRI. + */ +export class IRI implements Value { + + /** + * constructs an [IRI]. + * + * @param iri the IRI of a resource instance. + */ + constructor(readonly iri: string) { + } + + /** + * creates a SPARQL representation of the IRI. + * + * @param schema indicates the Knora schema to be used. + */ + public toSparql(): string { + // this is an instance Iri and does not have to be converted. + return `<${this.iri}>`; + } + +} + +/** + * represents a linked resource. + */ +export class LinkedResource implements Value { + + /** + * constructs a [LinkedResource]. + * + * @param properties the properties of the linked resource. + * @param resourceClass the class of the linked resource, if any. + */ + constructor(public properties: PropertyWithValue[], public resourceClass?: string) { + } + + public toSparql(): string { + throw Error('invalid call of toSparql'); + } + +} + +/** + * an abstract interface that represents a value. + * This interface has to be implemented for all value types (value component classes). + */ +export interface PropertyValue { + + /** + * type of the value. + */ + type: string; + + /** + * returns the value. + * + */ + getValue(): Value; + +} + +/** + * represents a property, the specified comparison operator, and value. + */ +export class PropertyWithValue { + + /** + * constructs a [PropertyWithValue]. + * + * @param property the specified property. + * @param valueLiteral the specified comparison operator and value. + * @param isSortCriterion indicates if the property is used as a sort criterion. + */ + constructor( + readonly property: ResourcePropertyDefinition, + readonly valueLiteral: ComparisonOperatorAndValue, + readonly isSortCriterion: boolean) { + } + +} + +/** + * a list, which is used in the mat-autocomplete form field + * contains objects with id and name. the id is usual the iri + */ +export interface AutocompleteItem { + iri: string; + name: string; + label?: string; +} diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-boolean-value/search-boolean-value.component.html b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-boolean-value/search-boolean-value.component.html new file mode 100644 index 0000000000..6e2b911096 --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-boolean-value/search-boolean-value.component.html @@ -0,0 +1,3 @@ + + + diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-boolean-value/search-boolean-value.component.scss b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-boolean-value/search-boolean-value.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-boolean-value/search-boolean-value.component.spec.ts b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-boolean-value/search-boolean-value.component.spec.ts new file mode 100644 index 0000000000..2c6ad0a5f4 --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-boolean-value/search-boolean-value.component.spec.ts @@ -0,0 +1,79 @@ +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { SearchBooleanValueComponent } from './search-boolean-value.component'; +import { Component, Inject, OnInit, ViewChild } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { ValueLiteral } from '../operator'; +import { MatCheckboxHarness } from '@angular/material/checkbox/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { MatCheckboxModule } from '@angular/material/checkbox'; + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostComponent implements OnInit { + + @ViewChild('boolVal', { static: false }) booleanValue: SearchBooleanValueComponent; + + form; + + constructor(@Inject(FormBuilder) private _fb: FormBuilder) { + } + + ngOnInit() { + this.form = this._fb.group({}); + + } +} + +describe('SearchBooleanValueComponent', () => { + let testHostComponent: TestHostComponent; + let testHostFixture: ComponentFixture; + + let loader: HarnessLoader; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + BrowserAnimationsModule, + ReactiveFormsModule, + MatCheckboxModule + ], + declarations: [ + SearchBooleanValueComponent, + TestHostComponent] + }) + .compileComponents(); + })); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostComponent); + testHostComponent = testHostFixture.componentInstance; + loader = TestbedHarnessEnvironment.loader(testHostFixture); + + testHostFixture.detectChanges(); + }); + + it('should create', () => { + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.booleanValue).toBeTruthy(); + }); + + it('should get a boolean literal true', async () => { + + const matCheckbox = await loader.getHarness(MatCheckboxHarness); + + await matCheckbox.check(); + + const expectedIntLiteralVal = new ValueLiteral('true', 'http://www.w3.org/2001/XMLSchema#boolean'); + + expect(testHostComponent.booleanValue.getValue()).toEqual(expectedIntLiteralVal); + + }); + +}); diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-boolean-value/search-boolean-value.component.ts b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-boolean-value/search-boolean-value.component.ts new file mode 100644 index 0000000000..3f792434c0 --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-boolean-value/search-boolean-value.component.ts @@ -0,0 +1,53 @@ +import { Component, Inject, Input, OnDestroy, OnInit } from '@angular/core'; +import { PropertyValue, Value, ValueLiteral } from '../operator'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { Constants } from '@dasch-swiss/dsp-js'; + +// https://stackoverflow.com/questions/45661010/dynamic-nested-reactive-form-expressionchangedafterithasbeencheckederror +const resolvedPromise = Promise.resolve(null); + + +@Component({ + selector: 'app-search-boolean-value', + templateUrl: './search-boolean-value.component.html', + styleUrls: ['./search-boolean-value.component.scss'] +}) +export class SearchBooleanValueComponent implements OnInit, OnDestroy, PropertyValue { + + // parent FormGroup + @Input() formGroup: FormGroup; + + type = Constants.BooleanValue; + + form: FormGroup; + + constructor(@Inject(FormBuilder) private _fb: FormBuilder) { + + } + + ngOnInit() { + + this.form = this._fb.group({ + booleanValue: [false, Validators.compose([Validators.required])] + }); + + resolvedPromise.then(() => { + // add form to the parent form group + this.formGroup.addControl('propValue', this.form); + }); + + } + + ngOnDestroy() { + + // remove form from the parent form group + resolvedPromise.then(() => { + this.formGroup.removeControl('propValue'); + }); + + } + + getValue(): Value { + return new ValueLiteral(String(this.form.value.booleanValue), Constants.XsdBoolean); + } +} diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-date-value/search-date-value.component.html b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-date-value/search-date-value.component.html new file mode 100644 index 0000000000..a82baa2de4 --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-date-value/search-date-value.component.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-date-value/search-date-value.component.scss b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-date-value/search-date-value.component.scss new file mode 100644 index 0000000000..86494d3087 --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-date-value/search-date-value.component.scss @@ -0,0 +1,9 @@ +// TODO: we can't override the overlay pane style here. But there should be a solution? +// at the moment, we have to add the following style to the main app style file +.cdk-overlay-pane { + .mat-datepicker-content { + .mat-calendar { + height: auto !important; + } + } +} diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-date-value/search-date-value.component.spec.ts b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-date-value/search-date-value.component.spec.ts new file mode 100644 index 0000000000..43d700ceb8 --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-date-value/search-date-value.component.spec.ts @@ -0,0 +1,87 @@ +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { Component, Inject, OnInit, ViewChild } from '@angular/core'; +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { MatNativeDateModule } from '@angular/material/core'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { MatInputModule } from '@angular/material/input'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { CalendarDate, CalendarPeriod, GregorianCalendarDate } from 'jdnconvertiblecalendar'; +import { CalendarHeaderComponent } from 'src/app/workspace/resource/values/date-value/calendar-header/calendar-header.component'; +import { JDNDatepickerDirective } from 'src/app/workspace/resource/values/jdn-datepicker-directive/jdndatepicker.directive'; +import { ValueLiteral } from '../operator'; +import { SearchDateValueComponent } from './search-date-value.component'; + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostComponent implements OnInit { + + @ViewChild('dateVal', { static: false }) dateValue: SearchDateValueComponent; + + form; + + constructor(@Inject(FormBuilder) private _fb: FormBuilder) { + } + + ngOnInit() { + this.form = this._fb.group({}); + } +} + +describe('SearchDateValueComponent', () => { + let testHostComponent: TestHostComponent; + let testHostFixture: ComponentFixture; + + let loader: HarnessLoader; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + BrowserAnimationsModule, + ReactiveFormsModule, + MatInputModule, + MatDatepickerModule, + MatNativeDateModule + ], + declarations: [ + CalendarHeaderComponent, + JDNDatepickerDirective, + SearchDateValueComponent, + TestHostComponent + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostComponent); + testHostComponent = testHostFixture.componentInstance; + loader = TestbedHarnessEnvironment.loader(testHostFixture); + + testHostFixture.detectChanges(); + }); + + it('should create', () => { + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.dateValue).toBeTruthy(); + }); + + it('should get a date', () => { + + const calDate = new CalendarDate(2018, 10, 30); + testHostComponent.dateValue.form.controls['dateValue'].setValue(new GregorianCalendarDate(new CalendarPeriod(calDate, calDate))); + + const gregorianDate = new ValueLiteral('GREGORIAN:2018-10-30:2018-10-30', 'http://api.knora.org/ontology/knora-api/simple/v2#Date'); + + const dateVal = testHostComponent.dateValue.getValue(); + + expect(dateVal).toEqual(gregorianDate); + + }); +}); diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-date-value/search-date-value.component.ts b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-date-value/search-date-value.component.ts new file mode 100644 index 0000000000..7774b6dd30 --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-date-value/search-date-value.component.ts @@ -0,0 +1,73 @@ +import { Component, Inject, Input, OnDestroy, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { Constants } from '@dasch-swiss/dsp-js'; +import { JDNConvertibleCalendar } from 'jdnconvertiblecalendar'; +import { CalendarHeaderComponent } from 'src/app/workspace/resource/values/date-value/calendar-header/calendar-header.component'; +import { PropertyValue, Value, ValueLiteral } from '../operator'; + +// https://stackoverflow.com/questions/45661010/dynamic-nested-reactive-form-expressionchangedafterithasbeencheckederror +const resolvedPromise = Promise.resolve(null); + +@Component({ + selector: 'app-search-date-value', + templateUrl: './search-date-value.component.html', + styleUrls: ['./search-date-value.component.scss'] +}) +export class SearchDateValueComponent implements OnInit, OnDestroy, PropertyValue { + + // parent FormGroup + @Input() formGroup: FormGroup; + + type = Constants.DateValue; + + form: FormGroup; + + // custom header for the datepicker + headerComponent = CalendarHeaderComponent; + + constructor(@Inject(FormBuilder) private _fb: FormBuilder) { + } + + ngOnInit() { + + // init datepicker + this.form = this._fb.group({ + dateValue: [null, Validators.compose([Validators.required])] + }); + + this.form.valueChanges.subscribe((data) => { + // console.log(data.dateValue); + }); + + resolvedPromise.then(() => { + // add form to the parent form group + this.formGroup.addControl('propValue', this.form); + }); + + } + + ngOnDestroy() { + + // remove form from the parent form group + resolvedPromise.then(() => { + this.formGroup.removeControl('propValue'); + }); + + } + + getValue(): Value { + + const dateObj: JDNConvertibleCalendar = this.form.value.dateValue; + + // get calendar format + const calendarFormat = dateObj.calendarName; + // get calendar period + const calendarPeriod = dateObj.toCalendarPeriod(); + // get the date + // eslint-disable-next-line max-len + const dateString = `${calendarFormat.toUpperCase()}:${calendarPeriod.periodStart.year}-${calendarPeriod.periodStart.month}-${calendarPeriod.periodStart.day}:${calendarPeriod.periodEnd.year}-${calendarPeriod.periodEnd.month}-${calendarPeriod.periodEnd.day}`; + + return new ValueLiteral(String(dateString), Constants.KnoraApi + '/ontology/knora-api/simple/v2' + Constants.HashDelimiter + 'Date'); + } + +} diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-decimal-value/search-decimal-value.component.html b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-decimal-value/search-decimal-value.component.html new file mode 100644 index 0000000000..ac04336f55 --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-decimal-value/search-decimal-value.component.html @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-decimal-value/search-decimal-value.component.scss b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-decimal-value/search-decimal-value.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-decimal-value/search-decimal-value.component.spec.ts b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-decimal-value/search-decimal-value.component.spec.ts new file mode 100644 index 0000000000..d66115a08d --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-decimal-value/search-decimal-value.component.spec.ts @@ -0,0 +1,88 @@ +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SearchDecimalValueComponent } from './search-decimal-value.component'; +import { Component, Inject, OnInit, ViewChild } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { MatInputModule } from '@angular/material/input'; +import { ValueLiteral } from '../operator'; +import { By } from '@angular/platform-browser'; + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostComponent implements OnInit { + + @ViewChild('decVal', { static: false }) decimalValue: SearchDecimalValueComponent; + + form; + + constructor(@Inject(FormBuilder) private _fb: FormBuilder) { + } + + ngOnInit() { + this.form = this._fb.group({}); + } +} + +describe('SearchDecimalValueComponent', () => { + let testHostComponent: TestHostComponent; + let testHostFixture: ComponentFixture; + + let loader: HarnessLoader; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + BrowserAnimationsModule, + ReactiveFormsModule, + MatInputModule + ], + declarations: [ + SearchDecimalValueComponent, + TestHostComponent + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostComponent); + testHostComponent = testHostFixture.componentInstance; + loader = TestbedHarnessEnvironment.loader(testHostFixture); + + testHostFixture.detectChanges(); + }); + + it('should create', () => { + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.decimalValue).toBeTruthy(); + }); + + it('should get a decimal literal of 1.1', () => { + + const hostCompDe = testHostFixture.debugElement; + + const decLiteralVal = new ValueLiteral('1.1', 'http://www.w3.org/2001/XMLSchema#decimal'); + + const decVal = hostCompDe.query(By.directive(SearchDecimalValueComponent)); + + const matInput = decVal.query(By.css('input')); + + matInput.nativeElement.value = '1.1'; + + matInput.triggerEventHandler('input', { target: matInput.nativeElement }); + + testHostFixture.detectChanges(); + + expect(testHostComponent.decimalValue.getValue()).toEqual(decLiteralVal); + + }); + +}); diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-decimal-value/search-decimal-value.component.ts b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-decimal-value/search-decimal-value.component.ts new file mode 100644 index 0000000000..b345d540f1 --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-decimal-value/search-decimal-value.component.ts @@ -0,0 +1,53 @@ +import { Component, Inject, Input, OnDestroy, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { Constants } from '@dasch-swiss/dsp-js'; +import { Value, ValueLiteral } from '../operator'; + +// https://stackoverflow.com/questions/45661010/dynamic-nested-reactive-form-expressionchangedafterithasbeencheckederror +const resolvedPromise = Promise.resolve(null); + +@Component({ + selector: 'app-search-decimal-value', + templateUrl: './search-decimal-value.component.html', + styleUrls: ['./search-decimal-value.component.scss'] +}) +export class SearchDecimalValueComponent implements OnInit, OnDestroy { + + // parent FormGroup + @Input() formGroup: FormGroup; + + type = Constants.DecimalValue; + + form: FormGroup; + + constructor(@Inject(FormBuilder) private _fb: FormBuilder) { + } + + ngOnInit() { + + this.form = this._fb.group({ + decimalValue: [null, Validators.compose([Validators.required])] + }); + + resolvedPromise.then(() => { + // add form to the parent form group + this.formGroup.addControl('propValue', this.form); + }); + + } + + ngOnDestroy() { + + // remove form from the parent form group + resolvedPromise.then(() => { + this.formGroup.removeControl('propValue'); + }); + + } + + getValue(): Value { + + return new ValueLiteral(String(this.form.value.decimalValue), Constants.XsdDecimal); + } + +} diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-int-value/search-int-value.component.html b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-int-value/search-int-value.component.html new file mode 100644 index 0000000000..81ca55a9c2 --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-int-value/search-int-value.component.html @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-int-value/search-int-value.component.scss b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-int-value/search-int-value.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-int-value/search-int-value.component.spec.ts b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-int-value/search-int-value.component.spec.ts new file mode 100644 index 0000000000..9a47d4853e --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-int-value/search-int-value.component.spec.ts @@ -0,0 +1,80 @@ +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SearchIntValueComponent } from './search-int-value.component'; +import { Component, Inject, OnInit, ViewChild } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { MatInputModule } from '@angular/material/input'; +import { ValueLiteral } from '../operator'; +import { MatInputHarness } from '@angular/material/input/testing'; + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostComponent implements OnInit { + + @ViewChild('intVal', { static: false }) integerValue: SearchIntValueComponent; + + form; + + constructor(@Inject(FormBuilder) private _fb: FormBuilder) { + } + + ngOnInit() { + this.form = this._fb.group({}); + } +} + +describe('SearchIntValueComponent', () => { + let testHostComponent: TestHostComponent; + let testHostFixture: ComponentFixture; + + let loader: HarnessLoader; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + BrowserAnimationsModule, + ReactiveFormsModule, + MatInputModule + ], + declarations: [ + SearchIntValueComponent, + TestHostComponent + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostComponent); + testHostComponent = testHostFixture.componentInstance; + loader = TestbedHarnessEnvironment.loader(testHostFixture); + + testHostFixture.detectChanges(); + }); + + it('should create', () => { + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.integerValue).toBeTruthy(); + }); + + it('should get an integer literal of 1', async () => { + + const matInput = await loader.getHarness(MatInputHarness); + + await matInput.setValue('1'); + + const expectedIntLiteralVal = new ValueLiteral('1', 'http://www.w3.org/2001/XMLSchema#integer'); + + expect(testHostComponent.integerValue.getValue()).toEqual(expectedIntLiteralVal); + + }); + +}); diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-int-value/search-int-value.component.ts b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-int-value/search-int-value.component.ts new file mode 100644 index 0000000000..234c63d3eb --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-int-value/search-int-value.component.ts @@ -0,0 +1,54 @@ +import { Component, Inject, Input, OnDestroy, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { Constants } from '@dasch-swiss/dsp-js'; +import { PropertyValue, Value, ValueLiteral } from '../operator'; + +// https://stackoverflow.com/questions/45661010/dynamic-nested-reactive-form-expressionchangedafterithasbeencheckederror +const resolvedPromise = Promise.resolve(null); + +@Component({ + selector: 'app-search-int-value', + templateUrl: './search-int-value.component.html', + styleUrls: ['./search-int-value.component.scss'] +}) +export class SearchIntValueComponent implements OnInit, OnDestroy, PropertyValue { + + // parent FormGroup + @Input() formGroup: FormGroup; + + type = Constants.IntValue; + + form: FormGroup; + + constructor(@Inject(FormBuilder) private _fb: FormBuilder) { + + } + + ngOnInit() { + + this.form = this._fb.group({ + integerValue: [null, Validators.compose([Validators.required, Validators.pattern(/^-?\d+$/)])] // only allow for integer values (no fractions) + }); + + resolvedPromise.then(() => { + // add form to the parent form group + this.formGroup.addControl('propValue', this.form); + }); + + } + + ngOnDestroy() { + + // remove form from the parent form group + resolvedPromise.then(() => { + this.formGroup.removeControl('propValue'); + }); + + } + + getValue(): Value { + + return new ValueLiteral(String(this.form.value.integerValue), Constants.XsdInteger); + } + +} diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-link-value/search-link-value.component.html b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-link-value/search-link-value.component.html new file mode 100644 index 0000000000..ac7f59f642 --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-link-value/search-link-value.component.html @@ -0,0 +1,11 @@ + + + + + + {{res?.label}} + + + + diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-link-value/search-link-value.component.scss b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-link-value/search-link-value.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-link-value/search-link-value.component.spec.ts b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-link-value/search-link-value.component.spec.ts new file mode 100644 index 0000000000..f460119a96 --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-link-value/search-link-value.component.spec.ts @@ -0,0 +1,154 @@ +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { Component, Inject, OnInit, ViewChild } from '@angular/core'; +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MatAutocompleteHarness } from '@angular/material/autocomplete/testing'; +import { MatInputModule } from '@angular/material/input'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { + ILabelSearchParams, + ReadResource, + ReadResourceSequence, + SearchEndpointV2 +} from '@dasch-swiss/dsp-js'; +import { of } from 'rxjs'; +import { DspApiConnectionToken } from 'src/app/main/declarations/dsp-api-tokens'; +import { IRI } from '../operator'; +import { SearchLinkValueComponent } from './search-link-value.component'; + + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostComponent implements OnInit { + + @ViewChild('linkVal', { static: false }) linkValue: SearchLinkValueComponent; + + form; + + resClass: string; + + constructor(@Inject(FormBuilder) private _fb: FormBuilder) { + } + + ngOnInit() { + this.form = this._fb.group({}); + this.resClass = 'http://0.0.0.0:3333/ontology/0001/anything/v2#Thing'; + } +} + +describe('SearchLinkValueComponent', () => { + let testHostComponent: TestHostComponent; + let testHostFixture: ComponentFixture; + + let loader: HarnessLoader; + + beforeEach(waitForAsync(() => { + + const searchSpyObj = { + v2: { + search: jasmine.createSpyObj('search', ['doSearchByLabel']), + } + }; + + TestBed.configureTestingModule({ + imports: [ + BrowserAnimationsModule, + ReactiveFormsModule, + MatAutocompleteModule, + MatInputModule + ], + declarations: [ + SearchLinkValueComponent, + TestHostComponent + ], + providers: [ + { + provide: DspApiConnectionToken, + useValue: searchSpyObj + } + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostComponent); + testHostComponent = testHostFixture.componentInstance; + loader = TestbedHarnessEnvironment.loader(testHostFixture); + + testHostFixture.detectChanges(); + }); + + it('should create', () => { + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.linkValue).toBeTruthy(); + }); + + it('should have the correct resource class restriction', () => { + // access the test host component's child + expect(testHostComponent.linkValue).toBeTruthy(); + + expect(testHostComponent.linkValue.restrictResourceClass).toEqual('http://0.0.0.0:3333/ontology/0001/anything/v2#Thing'); + }); + + it('should search for resources by their label', async () => { + + const searchSpy = TestBed.inject(DspApiConnectionToken); + + (searchSpy.v2.search as jasmine.SpyObj).doSearchByLabel.and.callFake( + (searchTerm: string, offset?: number, params?: ILabelSearchParams) => { + + const res = new ReadResource(); + res.id = 'http://testIri'; + res.label = 'testres'; + + const response = new ReadResourceSequence([res]); + + return of(response); + } + ); + + const autoCompleteHarness = await loader.getHarness(MatAutocompleteHarness); + + await autoCompleteHarness.enterText('testres'); + + const options = await autoCompleteHarness.getOptions(); + + expect(options.length).toEqual(1); + expect(await options[0].getText()).toEqual('testres'); + + expect(testHostComponent.linkValue.resources.length).toEqual(1); + expect(testHostComponent.linkValue.resources[0].id).toEqual('http://testIri'); + + expect(testHostComponent.linkValue.form.valid).toBe(false); + + await options[0].click(); + + expect(testHostComponent.linkValue.form.valid).toBe(true); + expect(testHostComponent.linkValue.form.controls['resource'].value.id).toEqual('http://testIri'); + + expect(searchSpy.v2.search.doSearchByLabel).toHaveBeenCalledTimes(5); // starts sending requests when 3 chars long: 'testres' -> tes (1) + tres (4) + expect(searchSpy.v2.search.doSearchByLabel).toHaveBeenCalledWith('testres', 0, { limitToResourceClass: 'http://0.0.0.0:3333/ontology/0001/anything/v2#Thing' }); + + }); + + it('should return a selected resource', () => { + + const res = new ReadResource(); + res.id = 'http://testIri'; + + testHostComponent.linkValue.form.controls['resource'].setValue(res); + + testHostFixture.detectChanges(); + + expect(testHostComponent.linkValue.getValue()).toEqual(new IRI('http://testIri')); + }); + +}); diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-link-value/search-link-value.component.ts b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-link-value/search-link-value.component.ts new file mode 100644 index 0000000000..9591fb97db --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-link-value/search-link-value.component.ts @@ -0,0 +1,129 @@ +import { Component, Inject, Input, OnDestroy, OnInit } from '@angular/core'; +import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; +import { Constants, KnoraApiConnection, ReadResource, ReadResourceSequence } from '@dasch-swiss/dsp-js'; +import { DspApiConnectionToken } from 'src/app/main/declarations/dsp-api-tokens'; +import { IRI, PropertyValue, Value } from '../operator'; + +// https://stackoverflow.com/questions/45661010/dynamic-nested-reactive-form-expressionchangedafterithasbeencheckederror +const resolvedPromise = Promise.resolve(null); + +@Component({ + selector: 'app-search-link-value', + templateUrl: './search-link-value.component.html', + styleUrls: ['./search-link-value.component.scss'] +}) +export class SearchLinkValueComponent implements OnInit, OnDestroy, PropertyValue { + + // parent FormGroup + @Input() formGroup: FormGroup; + + type = Constants.LinkValue; + + form: FormGroup; + + resources: ReadResource[]; + + private _restrictToResourceClass: string; + + @Input() + set restrictResourceClass(value: string) { + this._restrictToResourceClass = value; + } + + get restrictResourceClass() { + return this._restrictToResourceClass; + } + + constructor( + @Inject(DspApiConnectionToken) private _dspApiConnection: KnoraApiConnection, + @Inject(FormBuilder) private _fb: FormBuilder) { + } + + ngOnInit() { + this.form = this._fb.group({ + resource: [null, Validators.compose([ + Validators.required, + this.validateResource + ])] + }); + + this.form.valueChanges.subscribe((data) => { + this.searchByLabel(data.resource); + }); + + resolvedPromise.then(() => { + // add form to the parent form group + this.formGroup.addControl('propValue', this.form); + }); + } + + ngOnDestroy() { + + // remove form from the parent form group + resolvedPromise.then(() => { + this.formGroup.removeControl('propValue'); + }); + + } + + /** + * displays a selected resource using its label. + * + * @param resource the resource to be displayed (or no selection yet). + */ + displayResource(resource: ReadResource | null) { + + // null is the initial value (no selection yet) + if (resource !== null) { + return resource.label; + } + } + + /** + * search for resources whose labels contain the given search term, restricting to to the given properties object constraint. + * + * @param searchTerm the term to search for. + */ + searchByLabel(searchTerm: string) { + + // at least 3 characters are required + if (searchTerm.length >= 3) { + this._dspApiConnection.v2.search.doSearchByLabel(searchTerm, 0, { limitToResourceClass: this._restrictToResourceClass }).subscribe( + (response: ReadResourceSequence) => { + this.resources = response.resources; + }); + } else { + // clear selection + this.resources = undefined; + } + + } + + /** + * checks that the selection is a [[ReadResource]]. + * + * null is returned if the value is valid: https://angular.io/guide/form-validation#custom-validators + * + * @param form element whose value has to be checked. + */ + validateResource(c: FormControl) { + + const isValidResource = (c.value instanceof ReadResource); + + if (isValidResource) { + return null; + } else { + return { + noResource: { + value: c.value + } + }; + } + + } + + getValue(): Value { + return new IRI(this.form.value.resource.id); + } + +} diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-list-value/search-display-list/search-display-list.component.html b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-list-value/search-display-list/search-display-list.component.html new file mode 100644 index 0000000000..be368ea603 --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-list-value/search-display-list/search-display-list.component.html @@ -0,0 +1,16 @@ + + + + + + + + + + + + diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-list-value/search-display-list/search-display-list.component.scss b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-list-value/search-display-list/search-display-list.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-list-value/search-display-list/search-display-list.component.spec.ts b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-list-value/search-display-list/search-display-list.component.spec.ts new file mode 100644 index 0000000000..549c67b678 --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-list-value/search-display-list/search-display-list.component.spec.ts @@ -0,0 +1,110 @@ +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SearchDisplayListComponent } from './search-display-list.component'; +import { Component, OnInit, ViewChild } from '@angular/core'; +import { ListNodeV2, MockList } from '@dasch-swiss/dsp-js'; +import { ReactiveFormsModule } from '@angular/forms'; +import { HarnessLoader } from '@angular/cdk/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { MatMenuModule } from '@angular/material/menu'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { MatMenuItemHarness } from '@angular/material/menu/testing'; + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + + + ` +}) +class TestHostComponent implements OnInit { + + @ViewChild('menu') public searchDisplayListComponent: SearchDisplayListComponent; + + child: ListNodeV2; + selectedNode: ListNodeV2; + + constructor() { + } + + ngOnInit() { + this.child = MockList.mockList('http://rdfh.ch/lists/0001/treeList'); + } + + getSelectedNode(node: ListNodeV2) { + this.selectedNode = node; + } +} + +describe('SearchDisplayListComponent', () => { + let testHostComponent: TestHostComponent; + let testHostFixture: ComponentFixture; + + let loader: HarnessLoader; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + BrowserAnimationsModule, + ReactiveFormsModule, + MatMenuModule + ], + declarations: [ + SearchDisplayListComponent, + TestHostComponent + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostComponent); + testHostComponent = testHostFixture.componentInstance; + loader = TestbedHarnessEnvironment.loader(testHostFixture); + + testHostFixture.detectChanges(); + }); + + it('should create', () => { + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.searchDisplayListComponent).toBeTruthy(); + }); + + it('should pass the children via the @Input', () => { + expect(testHostComponent.searchDisplayListComponent.children.length).toEqual(3); + }); + + it('should display the children and emit the selected node', async () => { + + const menuItemButton = await loader.getHarness(MatMenuItemHarness.with({ selector: '.menubutton' })); + + await menuItemButton.click(); + + const subMenu = await menuItemButton.getSubmenu(); + + const subMenuItems = await subMenu.getItems(); + + expect(subMenuItems.length).toEqual(3); + + expect(await subMenuItems[0].hasSubmenu()).toBe(false); + + expect(await subMenuItems[1].hasSubmenu()).toBe(false); + + expect(await subMenuItems[2].hasSubmenu()).toBe(true); + + const subSubMenu = await subMenuItems[2].getSubmenu(); + + expect(await subSubMenu.getTriggerText()).toEqual('Tree list node 03'); + + await subMenuItems[0].click(); + + expect(testHostComponent.selectedNode).toBeDefined(); + expect(testHostComponent.selectedNode.id).toEqual('http://rdfh.ch/lists/0001/treeList01'); + + }); + +}); diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-list-value/search-display-list/search-display-list.component.ts b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-list-value/search-display-list/search-display-list.component.ts new file mode 100644 index 0000000000..74d8642bf9 --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-list-value/search-display-list/search-display-list.component.ts @@ -0,0 +1,25 @@ +import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'; +import { ListNodeV2 } from '@dasch-swiss/dsp-js'; +import { MatMenu } from '@angular/material/menu'; + +@Component({ + selector: 'app-search-display-list', + templateUrl: './search-display-list.component.html', + styleUrls: ['./search-display-list.component.scss'] +}) +export class SearchDisplayListComponent { + + @Input() children: ListNodeV2[]; + + @Output() selectedNode: EventEmitter = new EventEmitter(); + + @ViewChild('childMenu', { static: true }) public childMenu: MatMenu; + + constructor() { + } + + setValue(item: ListNodeV2) { + this.selectedNode.emit(item); + } + +} diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-list-value/search-list-value.component.html b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-list-value/search-list-value.component.html new file mode 100644 index 0000000000..98d6cf6f4b --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-list-value/search-list-value.component.html @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-list-value/search-list-value.component.scss b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-list-value/search-list-value.component.scss new file mode 100644 index 0000000000..f4d336fdf7 --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-list-value/search-list-value.component.scss @@ -0,0 +1,3 @@ +.hidden { + display: none; +} diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-list-value/search-list-value.component.spec.ts b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-list-value/search-list-value.component.spec.ts new file mode 100644 index 0000000000..5f237bfb8c --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-list-value/search-list-value.component.spec.ts @@ -0,0 +1,164 @@ +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { Component, EventEmitter, Inject, Input, OnInit, Output, ViewChild } from '@angular/core'; +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { MatButtonHarness } from '@angular/material/button/testing'; +import { MatInputModule } from '@angular/material/input'; +import { MatMenu, MatMenuModule } from '@angular/material/menu'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { ListNodeV2, ListsEndpointV2, MockList, MockOntology, ResourcePropertyDefinition } from '@dasch-swiss/dsp-js'; +import { of } from 'rxjs'; +import { DspApiConnectionToken } from 'src/app/main/declarations/dsp-api-tokens'; +import { IRI } from '../operator'; +import { SearchListValueComponent } from './search-list-value.component'; + + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostComponent implements OnInit { + + @ViewChild('listVal', { static: false }) listValue: SearchListValueComponent; + + form; + + property: ResourcePropertyDefinition; + + constructor(@Inject(FormBuilder) private _fb: FormBuilder) { + } + + ngOnInit() { + this.form = this._fb.group({}); + + const anythingOnto = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2'); + this.property = anythingOnto.properties['http://0.0.0.0:3333/ontology/0001/anything/v2#hasListItem'] as ResourcePropertyDefinition; + } +} + +/** + * test component to simulate date value component. + */ +@Component({ + selector: 'app-search-display-list', + template: '' +}) +class TestSearchDisplayListComponent implements OnInit { + + @Input() children: ListNodeV2[]; + + @Output() selectedNode: EventEmitter = new EventEmitter(); + + @ViewChild('childMenu', { static: true }) public childMenu: MatMenu; + + ngOnInit() { + + } + +} + +describe('SearchListValueComponent', () => { + let testHostComponent: TestHostComponent; + let testHostFixture: ComponentFixture; + + let loader: HarnessLoader; + + beforeEach(waitForAsync(() => { + + const listSpyObj = { + v2: { + list: jasmine.createSpyObj('list', ['getList']) + } + }; + + TestBed.configureTestingModule({ + imports: [ + BrowserAnimationsModule, + ReactiveFormsModule, + MatInputModule, + MatMenuModule, + MatSnackBarModule + ], + declarations: [ + SearchListValueComponent, + TestHostComponent, + TestSearchDisplayListComponent + ], + providers: [ + { + provide: DspApiConnectionToken, + useValue: listSpyObj + } + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + const listSpy = TestBed.inject(DspApiConnectionToken); + + (listSpy.v2.list as jasmine.SpyObj).getList.and.callFake( + (rootListNodeIri) => of(MockList.mockList('http://rdfh.ch/lists/0001/treeList')) + ); + + testHostFixture = TestBed.createComponent(TestHostComponent); + testHostComponent = testHostFixture.componentInstance; + loader = TestbedHarnessEnvironment.loader(testHostFixture); + + testHostFixture.detectChanges(); + }); + + it('should create', () => { + const dspSpy = TestBed.inject(DspApiConnectionToken); + + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.listValue).toBeTruthy(); + + expect(testHostComponent.listValue.listRootNode).toBeDefined(); + expect(testHostComponent.listValue.listRootNode.isRootNode).toBe(true); + + expect(dspSpy.v2.list.getList).toHaveBeenCalledTimes(1); + expect(dspSpy.v2.list.getList).toHaveBeenCalledWith('http://rdfh.ch/lists/0001/treeList'); + + }); + + it('should emit the selected node', async () => { + + const button = await loader.getHarness(MatButtonHarness); + + expect(await button.getText()).toEqual('Select list value'); + + await button.click(); + + const hostCompDe = testHostFixture.debugElement; + const searchDisplayListComponent = hostCompDe.query(By.directive(TestSearchDisplayListComponent)); + + expect(searchDisplayListComponent).not.toBeNull(); + + const listNode = new ListNodeV2(); + listNode.id = 'http://rdfh.ch/lists/0001/treeList03'; + + (searchDisplayListComponent.componentInstance as TestSearchDisplayListComponent).selectedNode.emit(listNode); + + expect(testHostComponent.listValue.form.controls['listValue'].value).toEqual('http://rdfh.ch/lists/0001/treeList03'); + + }); + + it('should get the selected list node', () => { + + testHostComponent.listValue.form.controls['listValue'].setValue('http://rdfh.ch/lists/0001/treeList/01'); + + const expectedListNode = new IRI('http://rdfh.ch/lists/0001/treeList/01'); + + const listNode = testHostComponent.listValue.getValue(); + + expect(listNode).toEqual(expectedListNode); + + }); +}); diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-list-value/search-list-value.component.ts b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-list-value/search-list-value.component.ts new file mode 100644 index 0000000000..c0605fac22 --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-list-value/search-list-value.component.ts @@ -0,0 +1,102 @@ +import { Component, Inject, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { MatMenuTrigger } from '@angular/material/menu'; +import { + ApiResponseError, + Constants, + KnoraApiConnection, + ListNodeV2, + ResourcePropertyDefinition +} from '@dasch-swiss/dsp-js'; +import { DspApiConnectionToken } from 'src/app/main/declarations/dsp-api-tokens'; +import { NotificationService } from 'src/app/main/services/notification.service'; +import { IRI, PropertyValue, Value } from '../operator'; + +// https://stackoverflow.com/questions/45661010/dynamic-nested-reactive-form-expressionchangedafterithasbeencheckederror +const resolvedPromise = Promise.resolve(null); + +@Component({ + selector: 'app-search-list-value', + templateUrl: './search-list-value.component.html', + styleUrls: ['./search-list-value.component.scss'] +}) +export class SearchListValueComponent implements OnInit, OnDestroy, PropertyValue { + + @Input() property: ResourcePropertyDefinition; + // parent FormGroup + @Input() formGroup: FormGroup; + + @ViewChild(MatMenuTrigger) menuTrigger: MatMenuTrigger; + + type = Constants.ListValue; + + form: FormGroup; + + listRootNode: ListNodeV2; + + selectedNode: ListNodeV2; + + + constructor( + @Inject(DspApiConnectionToken) private _dspApiConnection: KnoraApiConnection, + private _notification: NotificationService, + @Inject(FormBuilder) private _fb: FormBuilder + ) { + } + + ngOnInit() { + + this.form = this._fb.group({ + listValue: [null, Validators.required] + }); + + resolvedPromise.then(() => { + // add form to the parent form group + this.formGroup.addControl('propValue', this.form); + }); + + const rootNodeIri = this._getRootNodeIri(); + + this._dspApiConnection.v2.list.getList(rootNodeIri).subscribe( + (response: ListNodeV2) => { + this.listRootNode = response; + }, + (error: ApiResponseError) => { + this._notification.openSnackBar(error); + } + ); + + } + + ngOnDestroy() { + + // remove form from the parent form group + resolvedPromise.then(() => { + this.formGroup.removeControl('propValue'); + }); + + } + + getValue(): Value { + return new IRI(this.form.value.listValue); + } + + getSelectedNode(item: ListNodeV2) { + this.menuTrigger.closeMenu(); + this.selectedNode = item; + + this.form.controls['listValue'].setValue(item.id); + } + + private _getRootNodeIri(): string { + const guiAttr = this.property.guiAttributes; + + if (guiAttr.length === 1 && guiAttr[0].startsWith('hlist=')) { + const listNodeIri = guiAttr[0].substr(7, guiAttr[0].length - (1 + 7)); // hlist=<>, get also rid of <> + return listNodeIri; + } else { + console.log('No root node Iri given for property'); + } + } + +} diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-text-value/search-text-value.component.html b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-text-value/search-text-value.component.html new file mode 100644 index 0000000000..46111c61aa --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-text-value/search-text-value.component.html @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-text-value/search-text-value.component.scss b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-text-value/search-text-value.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-text-value/search-text-value.component.spec.ts b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-text-value/search-text-value.component.spec.ts new file mode 100644 index 0000000000..9155bab8a3 --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-text-value/search-text-value.component.spec.ts @@ -0,0 +1,79 @@ +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SearchTextValueComponent } from './search-text-value.component'; +import { Component, Inject, OnInit, ViewChild } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { HarnessLoader } from '@angular/cdk/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { MatInputModule } from '@angular/material/input'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { MatInputHarness } from '@angular/material/input/testing'; +import { ValueLiteral } from '../operator'; + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostComponent implements OnInit { + + @ViewChild('textVal') textValue: SearchTextValueComponent; + + form; + + constructor(@Inject(FormBuilder) private _fb: FormBuilder) { + } + + ngOnInit() { + this.form = this._fb.group({}); + } +} + +describe('SearchTextValueComponent', () => { + let testHostComponent: TestHostComponent; + let testHostFixture: ComponentFixture; + + let loader: HarnessLoader; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + BrowserAnimationsModule, + ReactiveFormsModule, + MatInputModule + ], + declarations: [ + SearchTextValueComponent, + TestHostComponent + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostComponent); + testHostComponent = testHostFixture.componentInstance; + loader = TestbedHarnessEnvironment.loader(testHostFixture); + + testHostFixture.detectChanges(); + }); + + it('should create', () => { + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.textValue).toBeTruthy(); + }); + + it('should get a text literal "test"', async () => { + + const matInput = await loader.getHarness(MatInputHarness); + + await matInput.setValue('test'); + + const textLiteralVal = new ValueLiteral('test', 'http://www.w3.org/2001/XMLSchema#string'); + + expect(testHostComponent.textValue.getValue()).toEqual(textLiteralVal); + + }); +}); diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-text-value/search-text-value.component.ts b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-text-value/search-text-value.component.ts new file mode 100644 index 0000000000..e00ed4ce30 --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-text-value/search-text-value.component.ts @@ -0,0 +1,52 @@ +import { Component, Inject, Input, OnDestroy, OnInit } from '@angular/core'; +import { PropertyValue, Value, ValueLiteral } from '../operator'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { Constants } from '@dasch-swiss/dsp-js'; + +// https://stackoverflow.com/questions/45661010/dynamic-nested-reactive-form-expressionchangedafterithasbeencheckederror +const resolvedPromise = Promise.resolve(null); + +@Component({ + selector: 'app-search-text-value', + templateUrl: './search-text-value.component.html', + styleUrls: ['./search-text-value.component.scss'] +}) +export class SearchTextValueComponent implements OnInit, OnDestroy, PropertyValue { + + // parent FormGroup + @Input() formGroup: FormGroup; + + type = Constants.TextValue; + + form: FormGroup; + + constructor(@Inject(FormBuilder) private _fb: FormBuilder) { + } + + ngOnInit() { + + this.form = this._fb.group({ + textValue: [null, Validators.required] + }); + + resolvedPromise.then(() => { + // add form to the parent form group + this.formGroup.addControl('propValue', this.form); + }); + + } + + ngOnDestroy() { + + // remove form from the parent form group + resolvedPromise.then(() => { + this.formGroup.removeControl('propValue'); + }); + + } + + getValue(): Value { + return new ValueLiteral(String(this.form.value.textValue), Constants.XsdString); + } + +} diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-uri-value/search-uri-value.component.html b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-uri-value/search-uri-value.component.html new file mode 100644 index 0000000000..5e4ed10041 --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-uri-value/search-uri-value.component.html @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-uri-value/search-uri-value.component.scss b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-uri-value/search-uri-value.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-uri-value/search-uri-value.component.spec.ts b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-uri-value/search-uri-value.component.spec.ts new file mode 100644 index 0000000000..7912647936 --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-uri-value/search-uri-value.component.spec.ts @@ -0,0 +1,80 @@ +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SearchUriValueComponent } from './search-uri-value.component'; +import { Component, Inject, OnInit, ViewChild } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { HarnessLoader } from '@angular/cdk/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { MatInputModule } from '@angular/material/input'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { MatInputHarness } from '@angular/material/input/testing'; +import { ValueLiteral } from '../operator'; + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostComponent implements OnInit { + + @ViewChild('uriVal') uriValue: SearchUriValueComponent; + + form; + + constructor(@Inject(FormBuilder) private _fb: FormBuilder) { + } + + ngOnInit() { + this.form = this._fb.group({}); + } +} + +describe('SearchUriValueComponent', () => { + let testHostComponent: TestHostComponent; + let testHostFixture: ComponentFixture; + + let loader: HarnessLoader; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + BrowserAnimationsModule, + ReactiveFormsModule, + MatInputModule + ], + declarations: [ + SearchUriValueComponent, + TestHostComponent + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostComponent); + testHostComponent = testHostFixture.componentInstance; + loader = TestbedHarnessEnvironment.loader(testHostFixture); + + testHostFixture.detectChanges(); + }); + + it('should create', () => { + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.uriValue).toBeTruthy(); + }); + + it('should get a URI literal of test', async () => { + const matInput = await loader.getHarness(MatInputHarness); + + await matInput.setValue('http://www.knora.org'); + + const uriLiteralVal = new ValueLiteral('http://www.knora.org', 'http://www.w3.org/2001/XMLSchema#anyURI'); + + expect(testHostComponent.uriValue.getValue()).toEqual(uriLiteralVal); + expect(testHostComponent.uriValue.form.valid).toBe(true); + + }); + +}); diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-uri-value/search-uri-value.component.ts b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-uri-value/search-uri-value.component.ts new file mode 100644 index 0000000000..efd2d1e55a --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/search-uri-value/search-uri-value.component.ts @@ -0,0 +1,55 @@ +import { Component, Inject, Input, OnInit, OnDestroy } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { Constants } from '@dasch-swiss/dsp-js'; +import { CustomRegex } from 'src/app/workspace/resource/values/custom-regex'; +import { PropertyValue, Value, ValueLiteral } from '../operator'; + +// https://stackoverflow.com/questions/45661010/dynamic-nested-reactive-form-expressionchangedafterithasbeencheckederror +const resolvedPromise = Promise.resolve(null); + +@Component({ + selector: 'app-search-uri-value', + templateUrl: './search-uri-value.component.html', + styleUrls: ['./search-uri-value.component.scss'] +}) +export class SearchUriValueComponent implements OnInit, OnDestroy, PropertyValue { + + // parent FormGroup + @Input() formGroup: FormGroup; + + type = Constants.UriValue; + + form: FormGroup; + + constructor(@Inject(FormBuilder) private _fb: FormBuilder) { + + } + + ngOnInit() { + + this.form = this._fb.group({ + uriValue: [null, Validators.compose([Validators.required, Validators.pattern(CustomRegex.URI_REGEX)])] + }); + + resolvedPromise.then(() => { + // add form to the parent form group + this.formGroup.addControl('propValue', this.form); + }); + + } + + ngOnDestroy() { + + // remove form from the parent form group + resolvedPromise.then(() => { + this.formGroup.removeControl('propValue'); + }); + + } + + getValue(): Value { + + return new ValueLiteral(String(this.form.value.uriValue), Constants.XsdAnyUri); + } + +} diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/specify-property-value.component.html b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/specify-property-value.component.html new file mode 100644 index 0000000000..79d80a5407 --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/specify-property-value.component.html @@ -0,0 +1,33 @@ + + + + {{ compOp.label }} + + + + + + + + + + + + + + + + + + + + + + + + Not supported {{propertyValueType}} + + + + diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/specify-property-value.component.scss b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/specify-property-value.component.scss new file mode 100644 index 0000000000..2de6a810f5 --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/specify-property-value.component.scss @@ -0,0 +1,3 @@ +.search-operator-field { + margin-right: 8px; +} diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/specify-property-value.component.spec.ts b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/specify-property-value.component.spec.ts new file mode 100644 index 0000000000..ab9687b137 --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/specify-property-value.component.spec.ts @@ -0,0 +1,375 @@ +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SpecifyPropertyValueComponent } from './specify-property-value.component'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { MatSelectModule } from '@angular/material/select'; +import { MatOptionModule } from '@angular/material/core'; +import { Constants, MockOntology, ResourcePropertyDefinition } from '@dasch-swiss/dsp-js'; +import { Component, Inject, Input, OnInit, ViewChild } from '@angular/core'; +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { MatSelectHarness } from '@angular/material/select/testing'; +import { IRI, Value, ValueLiteral } from './operator'; + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostComponent implements OnInit { + + @ViewChild('specifyProp') specifyProperty: SpecifyPropertyValueComponent; + + form: FormGroup; + + propertyDef: ResourcePropertyDefinition; + + topLevel = true; + + constructor(@Inject(FormBuilder) private _fb: FormBuilder) { + } + + ngOnInit() { + this.form = this._fb.group({}); + + const resProps = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2').getPropertyDefinitionsByType(ResourcePropertyDefinition); + + this.propertyDef = resProps.filter(propDef => propDef.id === 'http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger')[0]; + } + +} + +/** + * test component to simulate int value component. + */ +@Component({ + selector: 'app-search-int-value', + template: '' +}) +class TestSearchIntValueComponent implements OnInit { + + @Input() formGroup: FormGroup; + + getValue(): Value { + return new ValueLiteral(String(1), Constants.XsdInteger); + } + + ngOnInit() { + + } + +} + +/** + * test component to simulate decimal value component. + */ +@Component({ + selector: 'app-search-decimal-value', + template: '' +}) +class TestSearchDecimalValueComponent implements OnInit { + + @Input() formGroup: FormGroup; + + getValue(): Value { + return new ValueLiteral(String(1.1), Constants.XsdDecimal); + } + + ngOnInit() { + + } + +} + +/** + * test component to simulate Boolean value component. + */ +@Component({ + selector: 'app-search-boolean-value', + template: '' +}) +class TestSearchBooleanValueComponent implements OnInit { + + @Input() formGroup: FormGroup; + + getValue(): Value { + return new ValueLiteral(String(true), Constants.XsdBoolean); + } + + ngOnInit() { + + } + +} + +/** + * test component to simulate date value component. + */ +@Component({ + selector: 'app-search-date-value', + template: '' +}) +class TestSearchDateValueComponent implements OnInit { + + @Input() formGroup: FormGroup; + + getValue(): Value { + return new ValueLiteral('GREGORIAN:2018-10-30:2018-10-30', 'http://api.knora.org/ontology/knora-api/simple/v2#Date'); + } + + ngOnInit() { + + } + +} + +/** + * test component to simulate list value component. + */ +@Component({ + selector: 'app-search-list-value', + template: '' +}) +class TestSearchListValueComponent implements OnInit { + + @Input() formGroup: FormGroup; + + @Input() property: ResourcePropertyDefinition; + + getValue(): Value { + return new IRI('testIri'); + } + + ngOnInit() { + + } + +} + +/** + * test component to simulate text value component. + */ +@Component({ + selector: 'app-search-text-value', + template: '' +}) +class TestSearchTextValueComponent implements OnInit { + + @Input() formGroup: FormGroup; + + getValue(): Value { + return new ValueLiteral('test', 'http://www.w3.org/2001/XMLSchema#string'); + } + + ngOnInit() { + + } + +} + +/** + * test component to simulate uri value component. + */ +@Component({ + selector: 'app-search-uri-value', + template: '' +}) +class TestSearchUriValueComponent implements OnInit { + + @Input() formGroup: FormGroup; + + getValue(): Value { + return new ValueLiteral('http://www.knora.org', 'http://www.w3.org/2001/XMLSchema#anyURI'); + } + + ngOnInit() { + + } + +} + +describe('SpecifyPropertyValueComponent', () => { + let testHostComponent: TestHostComponent; + let testHostFixture: ComponentFixture; + + let loader: HarnessLoader; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + BrowserAnimationsModule, + ReactiveFormsModule, + MatSelectModule, + MatOptionModule + ], + declarations: [ + SpecifyPropertyValueComponent, + TestHostComponent, + TestSearchIntValueComponent, + TestSearchBooleanValueComponent, + TestSearchDateValueComponent, + TestSearchDecimalValueComponent, + TestSearchListValueComponent, + TestSearchTextValueComponent, + TestSearchUriValueComponent + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostComponent); + testHostComponent = testHostFixture.componentInstance; + loader = TestbedHarnessEnvironment.loader(testHostFixture); + + testHostFixture.detectChanges(); + }); + + it('should create', () => { + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.specifyProperty).toBeTruthy(); + }); + + it('should initialise the Inputs correctly', () => { + + expect(testHostComponent.specifyProperty.formGroup).toBeDefined(); + expect(testHostComponent.specifyProperty.property).toBeDefined(); + expect(testHostComponent.specifyProperty.property.id).toEqual('http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger'); + expect(testHostComponent.specifyProperty.topLevel).toBeTrue(); + }); + + it('should initialise the Inputs correctly for a linking prop with object class constraint', () => { + + const resProps = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2').getPropertyDefinitionsByType(ResourcePropertyDefinition); + + testHostComponent.propertyDef = resProps.filter(propDef => propDef.id === 'http://0.0.0.0:3333/ontology/0001/anything/v2#hasOtherThing')[0]; + + testHostFixture.detectChanges(); + + expect(testHostComponent.specifyProperty.formGroup).toBeDefined(); + expect(testHostComponent.specifyProperty.property).toBeDefined(); + expect(testHostComponent.specifyProperty.property.id).toEqual('http://0.0.0.0:3333/ontology/0001/anything/v2#hasOtherThing'); + expect(testHostComponent.specifyProperty.topLevel).toBeTrue(); + + expect(testHostComponent.specifyProperty.objectClassConstraint).toEqual('http://0.0.0.0:3333/ontology/0001/anything/v2#Thing'); + + }); + + it('should initialise the Inputs correctly for a linking prop without object class constraint', () => { + + const resProps = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2').getPropertyDefinitionsByType(ResourcePropertyDefinition); + + testHostComponent.propertyDef = resProps.filter(propDef => propDef.id === 'http://0.0.0.0:3333/ontology/0001/anything/v2#hasOtherThing')[0]; + testHostComponent.propertyDef.objectType = undefined; + + testHostFixture.detectChanges(); + + expect(testHostComponent.specifyProperty.formGroup).toBeDefined(); + expect(testHostComponent.specifyProperty.property).toBeDefined(); + expect(testHostComponent.specifyProperty.property.id).toEqual('http://0.0.0.0:3333/ontology/0001/anything/v2#hasOtherThing'); + expect(testHostComponent.specifyProperty.topLevel).toBeTrue(); + + expect(testHostComponent.specifyProperty.objectClassConstraint).toEqual('http://api.knora.org/ontology/knora-api/v2#Resource'); + + }); + + it('should add a new control to the parent form', waitForAsync(() => { + + // the control is added to the form as an async operation + // https://angular.io/guide/testing#async-test-with-async + testHostFixture.whenStable().then( + () => { + expect(testHostComponent.form.contains('comparisonOperator')).toBe(true); + } + ); + + })); + + it('should set the correct comparison operators for the given property type', () => { + expect(testHostComponent.specifyProperty.comparisonOperators.length).toEqual(7); + }); + + it('should set the correct comparison operators for a linking property type (on top level)', () => { + + const resProps = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2').getPropertyDefinitionsByType(ResourcePropertyDefinition); + + testHostComponent.propertyDef = resProps.filter(propDef => propDef.id === 'http://0.0.0.0:3333/ontology/0001/anything/v2#hasOtherThing')[0]; + + testHostFixture.detectChanges(); + + expect(testHostComponent.specifyProperty.comparisonOperators.length).toEqual(4); + expect(testHostComponent.topLevel).toBeTrue(); + }); + + it('should set the correct comparison operators for a linking property type (not on top level)', () => { + + const resProps = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2').getPropertyDefinitionsByType(ResourcePropertyDefinition); + + testHostComponent.propertyDef = resProps.filter(propDef => propDef.id === 'http://0.0.0.0:3333/ontology/0001/anything/v2#hasOtherThing')[0]; + + testHostComponent.topLevel = false; + + testHostFixture.detectChanges(); + + expect(testHostComponent.specifyProperty.comparisonOperators.length).toEqual(3); + expect(testHostComponent.topLevel).toBeFalse(); + }); + + it('should init the MatSelect and MatOptions correctly', async () => { + + const select = await loader.getHarness(MatSelectHarness); + const initVal = await select.getValueText(); + + // placeholder + expect(initVal).toEqual('Comparison Operator'); + + await select.open(); + + const options = await select.getOptions(); + + expect(options.length).toEqual(7); + + }); + + it('should set the form to valid when an comparison operator has been chosen', async () => { + + expect(testHostComponent.specifyProperty.form.valid).toBe(false); + + const select = await loader.getHarness(MatSelectHarness); + + await select.open(); + + const options = await select.getOptions(); + + await options[0].click(); + + expect(testHostComponent.specifyProperty.form.valid).toBe(true); + + }); + + it('should read a value after a comparison operator has been chosen', async () => { + const select = await loader.getHarness(MatSelectHarness); + + await select.open(); + + const options = await select.getOptions(); + + await options[0].click(); + + expect(testHostComponent.specifyProperty.propertyValueComponent.getValue()).toEqual(new ValueLiteral('1', Constants.XsdInteger)); + + }); + + it('should unsubscribe from from changes on destruction', () => { + + expect(testHostComponent.specifyProperty.comparisonOperatorChangesSubscription.closed).toBe(false); + + testHostFixture.destroy(); + + expect(testHostComponent.specifyProperty.comparisonOperatorChangesSubscription.closed).toBe(true); + + }); +}); diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/specify-property-value.component.ts b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/specify-property-value.component.ts new file mode 100644 index 0000000000..78a96d2040 --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-property/specify-property-value/specify-property-value.component.ts @@ -0,0 +1,189 @@ +import { Component, Inject, Input, OnChanges, OnDestroy, ViewChild } from '@angular/core'; +import { Constants, ResourcePropertyDefinition } from '@dasch-swiss/dsp-js'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { + ComparisonOperator, ComparisonOperatorAndValue, + Equals, + Exists, + GreaterThan, + GreaterThanEquals, + LessThan, + LessThanEquals, + Like, + Match, + NotEquals, PropertyValue, Value +} from './operator'; +import { Subscription } from 'rxjs'; + +// https://stackoverflow.com/questions/45661010/dynamic-nested-reactive-form-expressionchangedafterithasbeencheckederror +const resolvedPromise = Promise.resolve(null); + +@Component({ + selector: 'app-specify-property-value', + templateUrl: './specify-property-value.component.html', + styleUrls: ['./specify-property-value.component.scss'] +}) +export class SpecifyPropertyValueComponent implements OnChanges, OnDestroy { + + // parent FormGroup + @Input() formGroup: FormGroup; + + @Input() topLevel: boolean; + + @ViewChild('propertyValue', { static: false }) propertyValueComponent: PropertyValue; + + Constants = Constants; + + objectClassConstraint: string; + + // setter method for the property chosen by the user + @Input() + set property(prop: ResourcePropertyDefinition) { + this._property = prop; + } + + // getter method for this._property + get property(): ResourcePropertyDefinition { + return this._property; + } + + form: FormGroup; + + // available comparison operators for the property + comparisonOperators: Array = []; + + // comparison operator selected by the user + comparisonOperatorSelected: ComparisonOperator; + + // the type of the property + propertyValueType; + + comparisonOperatorChangesSubscription: Subscription; + + private _property: ResourcePropertyDefinition; + + constructor(@Inject(FormBuilder) private _fb: FormBuilder) { + } + + ngOnChanges(): void { + + // build a form for comparison operator selection + this.form = this._fb.group({ + comparisonOperator: [null, Validators.required] + }); + + this._closeComparisonOperatorChangesSubscription(); + + // store comparison operator when selected + this.comparisonOperatorChangesSubscription = this.form.valueChanges.subscribe((data) => { + this.comparisonOperatorSelected = data.comparisonOperator; + }); + + // comparison operator selection + this.comparisonOperatorSelected = undefined; // reset to initial state + this.resetComparisonOperators(); // reset comparison operators for given property (overwriting any previous selection) + + // use knora-api:Resource as a fallback + this.objectClassConstraint = (this.property.isLinkProperty && this.property.objectType !== undefined) ? this.property.objectType : Constants.Resource; + + resolvedPromise.then(() => { + + // remove from the parent form group (clean reset) + this.formGroup.removeControl('comparisonOperator'); + + // add form to the parent form group + this.formGroup.addControl('comparisonOperator', this.form); + }); + + } + + ngOnDestroy() { + this._closeComparisonOperatorChangesSubscription(); + } + + /** + * resets the comparison operators for this._property. + */ + resetComparisonOperators() { + + // depending on object class, set comparison operators and value entry field + if (this._property.isLinkProperty) { + this.propertyValueType = Constants.Resource; + } else { + this.propertyValueType = this._property.objectType; + } + + switch (this.propertyValueType) { + + case Constants.TextValue: + this.comparisonOperators = [new Like(), new Match(), new Equals(), new NotEquals(), new Exists()]; + break; + + case Constants.BooleanValue: + case Constants.UriValue: + this.comparisonOperators = [new Equals(), new NotEquals(), new Exists()]; + break; + + case Constants.Resource: // tODO: Match is only available on top level + this.comparisonOperators = this.topLevel ? [new Equals(), new NotEquals(), new Exists(), new Match()] : [new Equals(), new NotEquals(), new Exists()]; + break; + + case Constants.IntValue: + case Constants.DecimalValue: + case Constants.DateValue: + this.comparisonOperators = [new Equals(), new NotEquals(), new LessThan(), new LessThanEquals(), new GreaterThan(), new GreaterThanEquals(), new Exists()]; + break; + + case Constants.ListValue: + this.comparisonOperators = [new Equals(), new NotEquals(), new Exists()]; + break; + + case Constants.GeomValue: + case Constants.FileValue: + case Constants.AudioFileValue: + case Constants.StillImageFileValue: + case Constants.DDDFileValue: + case Constants.MovingImageFileValue: + case Constants.TextFileValue: + case Constants.ColorValue: + case Constants.IntervalValue: + case Constants.GeonameValue: + this.comparisonOperators = [new Exists()]; + break; + + default: + console.log('ERROR: Unsupported value type ' + this._property.objectType); + + } + + } + + /** + * gets the specified comparison operator and value for the property. + * + * returns {ComparisonOperatorAndValue} the comparison operator and the specified value + */ + getComparisonOperatorAndValueLiteralForProperty(): ComparisonOperatorAndValue { + // return value (literal or IRI) from the child component + let value: Value; + + // comparison operator 'Exists' does not require a value + if (this.comparisonOperatorSelected.getClassName() !== 'Exists') { + value = this.propertyValueComponent.getValue(); + } + + // return the comparison operator and the specified value + return new ComparisonOperatorAndValue(this.comparisonOperatorSelected, value); + + } + + /** + * unsubscribe from form changes. + */ + private _closeComparisonOperatorChangesSubscription() { + if (this.comparisonOperatorChangesSubscription !== undefined) { + this.comparisonOperatorChangesSubscription.unsubscribe(); + } + } + +} diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-resource-class/search-select-resource-class.component.html b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-resource-class/search-select-resource-class.component.html new file mode 100644 index 0000000000..7d83d728a8 --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-resource-class/search-select-resource-class.component.html @@ -0,0 +1,10 @@ + + + + no selection + + {{ resourceClass.label }} + + + + diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-resource-class/search-select-resource-class.component.scss b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-resource-class/search-select-resource-class.component.scss new file mode 100644 index 0000000000..2a8509a7d6 --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-resource-class/search-select-resource-class.component.scss @@ -0,0 +1,3 @@ +.select-resource-class { + margin-left: 8px; +} diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-resource-class/search-select-resource-class.component.spec.ts b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-resource-class/search-select-resource-class.component.spec.ts new file mode 100644 index 0000000000..4c04d70596 --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-resource-class/search-select-resource-class.component.spec.ts @@ -0,0 +1,204 @@ +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Component, Inject, OnInit, ViewChild } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { MockOntology, ResourceClassDefinition } from '@dasch-swiss/dsp-js'; +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { MatSelectModule } from '@angular/material/select'; +import { MatOptionModule } from '@angular/material/core'; +import { MatSelectHarness } from '@angular/material/select/testing'; +import { SearchSelectResourceClassComponent } from './search-select-resource-class.component'; + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostComponent implements OnInit { + + @ViewChild('selectResClass') selectResourceClass: SearchSelectResourceClassComponent; + + form: FormGroup; + + resourceClassDefs: ResourceClassDefinition[]; + + selectedResClassIri: string; + + constructor(@Inject(FormBuilder) private _fb: FormBuilder) { + } + + ngOnInit() { + this.form = this._fb.group({}); + + // get resource class defs + this.resourceClassDefs = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2').getClassDefinitionsByType(ResourceClassDefinition); + + } + + resClassSelected(resClassIri: string) { + this.selectedResClassIri = resClassIri; + } +} + + +describe('SelectResourceClassComponent', () => { + let testHostComponent: TestHostComponent; + let testHostFixture: ComponentFixture; + + let loader: HarnessLoader; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + BrowserAnimationsModule, + ReactiveFormsModule, + MatSelectModule, + MatOptionModule + ], + declarations: [ + SearchSelectResourceClassComponent, + TestHostComponent + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostComponent); + testHostComponent = testHostFixture.componentInstance; + loader = TestbedHarnessEnvironment.loader(testHostFixture); + + testHostFixture.detectChanges(); + }); + + it('should create', () => { + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.selectResourceClass).toBeTruthy(); + }); + + it('should add a new control to the parent form', () => { + + expect(testHostComponent.form.contains('resourceClass')).toBe(true); + + }); + + it('should initialise the resource class definitions correctly', () => { + + expect(testHostComponent.selectResourceClass.resourceClassDefinitions.length).toEqual(8); + + }); + + it('should init the MatSelect and MatOptions correctly', async () => { + + const select = await loader.getHarness(MatSelectHarness); + const initVal = await select.getValueText(); + + // placeholder + expect(initVal).toEqual('Select a Resource Class (optional)'); + + await select.open(); + + const options = await select.getOptions(); + + expect(options.length).toEqual(9); + + const option1 = await options[0].getText(); + + expect(option1).toEqual('no selection'); + + const option2 = await options[1].getText(); + + expect(option2).toEqual('Blue thing'); + + }); + + it('should emit the Iri of a selected resource class', async () => { + + expect(testHostComponent.selectedResClassIri).toBeUndefined(); + expect(testHostComponent.selectResourceClass.selectedResourceClassIri).toBe(false); + + const select = await loader.getHarness(MatSelectHarness); + + await select.open(); + + const options = await select.getOptions({ text: 'Blue thing' }); + + expect(options.length).toEqual(1); + + await options[0].click(); + + expect(testHostComponent.selectedResClassIri).toEqual('http://0.0.0.0:3333/ontology/0001/anything/v2#BlueThing'); + + expect(testHostComponent.selectResourceClass.selectedResourceClassIri).toEqual('http://0.0.0.0:3333/ontology/0001/anything/v2#BlueThing'); + + }); + + it('should select the option "no selection"', async () => { + + expect(testHostComponent.selectedResClassIri).toBeUndefined(); + expect(testHostComponent.selectResourceClass.selectedResourceClassIri).toBe(false); + + const select = await loader.getHarness(MatSelectHarness); + + await select.open(); + + const options = await select.getOptions({ text: 'Blue thing' }); + + expect(options.length).toEqual(1); + + await options[0].click(); + + const options2 = await select.getOptions({ text: 'no selection' }); + + expect(options2.length).toEqual(1); + + await options2[0].click(); + + expect(testHostComponent.selectedResClassIri).toBeNull(); + + }); + + it('should update the resource class definitions when the @Input changes', async () => { + + // simulate an existing resource class selection + testHostComponent.selectResourceClass['_selectedResourceClassIri'] = 'http://0.0.0.0:3333/ontology/0001/anything/v2#BlueThing'; + + // get resource class defs + testHostComponent.resourceClassDefs = MockOntology.mockReadOntology('http://api.knora.org/ontology/knora-api/v2').getClassDefinitionsByType(ResourceClassDefinition); + + testHostFixture.detectChanges(); + + expect(testHostComponent.selectResourceClass.resourceClassDefinitions.length).toEqual(12); + + const select = await loader.getHarness(MatSelectHarness); + const initVal = await select.getValueText(); + + // placeholder + expect(initVal).toEqual('Select a Resource Class (optional)'); + + await select.open(); + + const options = await select.getOptions(); + + expect(options.length).toEqual(13); + + expect(testHostComponent.selectResourceClass.selectedResourceClassIri).toBe(false); + + }); + + it('should unsubscribe from from changes on destruction', () => { + + expect(testHostComponent.selectResourceClass.ontologyChangesSubscription.closed).toBe(false); + + testHostFixture.destroy(); + + expect(testHostComponent.selectResourceClass.ontologyChangesSubscription.closed).toBe(true); + + }); + +}); diff --git a/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-resource-class/search-select-resource-class.component.ts b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-resource-class/search-select-resource-class.component.ts new file mode 100644 index 0000000000..c9e8c3f366 --- /dev/null +++ b/src/app/workspace/search/advanced-search/resource-and-property-selection/search-select-resource-class/search-select-resource-class.component.ts @@ -0,0 +1,113 @@ +import { + Component, + EventEmitter, + Inject, + Input, + OnChanges, + OnDestroy, + OnInit, + Output, + SimpleChanges +} from '@angular/core'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { ResourceClassDefinition } from '@dasch-swiss/dsp-js'; +import { Subscription } from 'rxjs'; + +// https://stackoverflow.com/questions/45661010/dynamic-nested-reactive-form-expressionchangedafterithasbeencheckederror +const resolvedPromise = Promise.resolve(null); + +@Component({ + selector: 'app-search-select-resource-class', + templateUrl: './search-select-resource-class.component.html', + styleUrls: ['./search-select-resource-class.component.scss'] +}) +export class SearchSelectResourceClassComponent implements OnInit, OnChanges, OnDestroy { + + @Input() formGroup: FormGroup; + + @Input() resourceClassDefinitions: ResourceClassDefinition[]; + + @Output() resourceClassSelected = new EventEmitter(); + + get selectedResourceClassIri(): string | false { + if (this._selectedResourceClassIri !== undefined && this._selectedResourceClassIri !== null) { + return this._selectedResourceClassIri; + } else { + return false; + } + } + + ontologyChangesSubscription: Subscription; + + form: FormGroup; + + // stores the currently selected resource class + private _selectedResourceClassIri: string; + + constructor(@Inject(FormBuilder) private _fb: FormBuilder) { + } + + ngOnInit(): void { + this._initForm(); + + // add form to the parent form group + this.formGroup.addControl('resourceClass', this.form); + } + + ngOnChanges(changes: SimpleChanges) { + + if (this.form !== undefined) { + + // resource classes have been reinitialized + // reset form + resolvedPromise.then(() => { + + // remove this form from the parent form group + this.formGroup.removeControl('resourceClass'); + + this._initForm(); + + // add form to the parent form group + this.formGroup.addControl('resourceClass', this.form); + + }); + + } + } + + ngOnDestroy() { + this._closeOntologyChangesSubscription(); + } + + /** + * initialises the FormGroup for the resource class selection. + * The initial value is set to null. + */ + private _initForm() { + // build a form for the resource class selection + this.form = this._fb.group({ + resourceClass: [null] // resource class selection is optional + }); + + // reset on updates of @Input resourceClassDefinitions + this._selectedResourceClassIri = undefined; + + this._closeOntologyChangesSubscription(); + + // store and emit Iri of the resource class when selected + this.ontologyChangesSubscription = this.form.valueChanges.subscribe((data) => { + this._selectedResourceClassIri = data.resourceClass; + this.resourceClassSelected.emit(this._selectedResourceClassIri); + }); + } + + /** + * unsubscribe from form changes. + */ + private _closeOntologyChangesSubscription() { + if (this.ontologyChangesSubscription !== undefined) { + this.ontologyChangesSubscription.unsubscribe(); + } + } + +} diff --git a/src/app/workspace/search/advanced-search/search-select-ontology/search-select-ontology.component.html b/src/app/workspace/search/advanced-search/search-select-ontology/search-select-ontology.component.html new file mode 100644 index 0000000000..78e6249093 --- /dev/null +++ b/src/app/workspace/search/advanced-search/search-select-ontology/search-select-ontology.component.html @@ -0,0 +1,7 @@ + + + + {{ onto.label }} + + + diff --git a/src/app/workspace/search/advanced-search/search-select-ontology/search-select-ontology.component.scss b/src/app/workspace/search/advanced-search/search-select-ontology/search-select-ontology.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/workspace/search/advanced-search/search-select-ontology/search-select-ontology.component.spec.ts b/src/app/workspace/search/advanced-search/search-select-ontology/search-select-ontology.component.spec.ts new file mode 100644 index 0000000000..714047e348 --- /dev/null +++ b/src/app/workspace/search/advanced-search/search-select-ontology/search-select-ontology.component.spec.ts @@ -0,0 +1,144 @@ +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Component, Inject, OnInit, ViewChild } from '@angular/core'; +import { OntologiesMetadata, OntologyMetadata, MockOntology } from '@dasch-swiss/dsp-js'; +import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { MatSelectModule } from '@angular/material/select'; +import { MatOptionModule } from '@angular/material/core'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { HarnessLoader } from '@angular/cdk/testing'; +import { MatSelectHarness } from '@angular/material/select/testing'; +import { SearchSelectOntologyComponent } from './search-select-ontology.component'; + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostComponent implements OnInit { + + @ViewChild('selectOnto') selectOntology: SearchSelectOntologyComponent; + + ontoMetadata: OntologiesMetadata; + + form: FormGroup; + + selectedOntoIri: string; + + constructor(@Inject(FormBuilder) private _fb: FormBuilder) { + } + + ngOnInit() { + this.form = this._fb.group({}); + + this.ontoMetadata = MockOntology.mockOntologiesMetadata(); + } + + ontoSelected(ontoIri: string) { + this.selectedOntoIri = ontoIri; + } +} + +describe('SearchSelectOntologyComponent', () => { + let testHostComponent: TestHostComponent; + let testHostFixture: ComponentFixture; + + let loader: HarnessLoader; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + BrowserAnimationsModule, + ReactiveFormsModule, + MatSelectModule, + MatOptionModule + ], + declarations: [ + SearchSelectOntologyComponent, + TestHostComponent] + }) + .compileComponents(); + })); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostComponent); + testHostComponent = testHostFixture.componentInstance; + loader = TestbedHarnessEnvironment.loader(testHostFixture); + + testHostFixture.detectChanges(); + }); + + it('should create', () => { + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.selectOntology).toBeTruthy(); + }); + + it('should add a new control to the parent form', () => { + + expect(testHostComponent.form.contains('ontologies')).toBe(true); + + }); + + it('should initialise the ontologies\' metadata', () => { + + expect(testHostComponent.selectOntology.ontologiesMetadata.ontologies).toBeDefined(); + expect(testHostComponent.selectOntology.ontologiesMetadata.ontologies.length).toEqual(14); + + }); + + it('should init the MatSelect and MatOptions correctly', async () => { + + const select = await loader.getHarness(MatSelectHarness); + const initVal = await select.getValueText(); + + // placeholder + expect(initVal).toEqual('Select an Ontology'); + + await select.open(); + + const options = await select.getOptions(); + + expect(options.length).toEqual(14); + + const option1 = await options[0].getText(); + + expect(option1).toEqual('The anything ontology'); + + const option2 = await options[1].getText(); + + expect(option2).toEqual('A minimal ontology'); + + }); + + it('should emit the Iri of a selected ontology', async () => { + + expect(testHostComponent.selectedOntoIri).toBeUndefined(); + + const select = await loader.getHarness(MatSelectHarness); + + await select.open(); + + const options = await select.getOptions({ text: 'The anything ontology' }); + + expect(options.length).toEqual(1); + + await options[0].click(); + + expect(testHostComponent.selectedOntoIri).toEqual('http://0.0.0.0:3333/ontology/0001/anything/v2'); + + }); + + it('should unsubscribe from from changes on destruction', () => { + + expect(testHostComponent.selectOntology.ontologyChangesSubscription.closed).toBe(false); + + testHostFixture.destroy(); + + expect(testHostComponent.selectOntology.ontologyChangesSubscription.closed).toBe(true); + + }); +}); diff --git a/src/app/workspace/search/advanced-search/search-select-ontology/search-select-ontology.component.ts b/src/app/workspace/search/advanced-search/search-select-ontology/search-select-ontology.component.ts new file mode 100644 index 0000000000..edc608255d --- /dev/null +++ b/src/app/workspace/search/advanced-search/search-select-ontology/search-select-ontology.component.ts @@ -0,0 +1,49 @@ +import { Component, EventEmitter, Inject, Input, OnDestroy, OnInit, Output } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { OntologiesMetadata } from '@dasch-swiss/dsp-js'; +import { Subscription } from 'rxjs'; + +@Component({ + selector: 'app-search-select-ontology', + templateUrl: './search-select-ontology.component.html', + styleUrls: ['./search-select-ontology.component.scss'] +}) +export class SearchSelectOntologyComponent implements OnInit, OnDestroy { + + @Input() formGroup: FormGroup; + + @Input() ontologiesMetadata: OntologiesMetadata; + + @Output() ontologySelected = new EventEmitter(); + + form: FormGroup; + + ontologyChangesSubscription: Subscription; + + constructor(@Inject(FormBuilder) private _fb: FormBuilder) { + } + + ngOnInit() { + + // build a form for the named graph selection + this.form = this._fb.group({ + ontologies: [null, Validators.required] + }); + + // emit Iri of the ontology when selected + this.ontologyChangesSubscription = this.form.valueChanges.subscribe((data) => { + this.ontologySelected.emit(data.ontologies); + }); + + // add form to the parent form group + this.formGroup.addControl('ontologies', this.form); + + } + + ngOnDestroy() { + if (this.ontologyChangesSubscription !== undefined) { + this.ontologyChangesSubscription.unsubscribe(); + } + } + +} diff --git a/src/app/workspace/search/expert-search/expert-search.component.html b/src/app/workspace/search/expert-search/expert-search.component.html new file mode 100644 index 0000000000..c6452da93c --- /dev/null +++ b/src/app/workspace/search/expert-search/expert-search.component.html @@ -0,0 +1,28 @@ +
+
+ + + + The OFFSET should not be provided in the query as it will be added automatically during + the submission of the form. + + + A Gravsearch query is required. + + + +
+ + + +
+ +
+
diff --git a/src/app/workspace/search/expert-search/expert-search.component.scss b/src/app/workspace/search/expert-search/expert-search.component.scss new file mode 100644 index 0000000000..5bf70f8d5b --- /dev/null +++ b/src/app/workspace/search/expert-search/expert-search.component.scss @@ -0,0 +1,35 @@ +.expert-search-container { + // min-height: 100%; + + .expert-search-form { + min-width: 150px; + width: 100%; + margin: auto; + + .textarea-field { + width: calc(100% - 24px); + display: block; + margin-bottom: 24px; + background: #fff; + padding: 12px; + } + + .form-panel { + width: 100%; + } + } +} + +.mat-input-element { + font-family: "Courier New", Courier, monospace; +} + +.form-content { + margin: 24px auto; + width: 472px; + + // TODO: css class to move in the main stylesheet in assets/style + .large-field { + min-width: 472px; + } +} diff --git a/src/app/workspace/search/expert-search/expert-search.component.spec.ts b/src/app/workspace/search/expert-search/expert-search.component.spec.ts new file mode 100644 index 0000000000..3a6a03af47 --- /dev/null +++ b/src/app/workspace/search/expert-search/expert-search.component.spec.ts @@ -0,0 +1,254 @@ +import { Component, DebugElement, OnInit, ViewChild } from '@angular/core'; +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { KnoraApiConfig } from '@dasch-swiss/dsp-js'; +import { DspApiConfigToken } from 'src/app/main/declarations/dsp-api-tokens'; +import { SearchParams } from '../../results/list-view/list-view.component'; +import { AdvancedSearchParams, AdvancedSearchParamsService } from '../services/advanced-search-params.service'; +import { ExpertSearchComponent } from './expert-search.component'; + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostComponent implements OnInit { + + @ViewChild('expSearch') expertSearch: ExpertSearchComponent; + + gravsearchQ: SearchParams; + + ngOnInit() { + } + + gravsearchQuery(query: SearchParams) { + this.gravsearchQ = query; + } + +} + +describe('ExpertSearchComponent', () => { + let testHostComponent: TestHostComponent; + let testHostFixture: ComponentFixture; + let hostCompDe: DebugElement; + + let searchParamsServiceSpy: jasmine.SpyObj; + let advancedSearchParams: AdvancedSearchParams; + + beforeEach(waitForAsync(() => { + + const dspConfSpy = new KnoraApiConfig('http', 'localhost', 3333, undefined, undefined, true); + + const spy = jasmine.createSpyObj('SearchParamsService', ['changeSearchParamsMsg']); + + TestBed.configureTestingModule({ + declarations: [ + ExpertSearchComponent, + TestHostComponent + ], + imports: [ + FormsModule, + ReactiveFormsModule, + BrowserAnimationsModule, + MatFormFieldModule, + MatInputModule + ], + providers: [ + { + provide: DspApiConfigToken, + useValue: dspConfSpy + }, + { + provide: AdvancedSearchParamsService, + useValue: spy + } + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostComponent); + testHostComponent = testHostFixture.componentInstance; + + searchParamsServiceSpy = TestBed.inject(AdvancedSearchParamsService) as jasmine.SpyObj; + searchParamsServiceSpy.changeSearchParamsMsg.and.callFake((searchParams: AdvancedSearchParams) => { + advancedSearchParams = searchParams; + }); + + testHostFixture.detectChanges(); + + hostCompDe = testHostFixture.debugElement; + }); + + it('should create', () => { + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.expertSearch).toBeTruthy(); + }); + + it('should init the form with the default query', () => { + const textarea = hostCompDe.query(By.css('textarea.textarea-field-content')); + const textareaEle = textarea.nativeElement; + + expect(textareaEle.value).toBe( + `PREFIX knora-api: +PREFIX incunabula: + +CONSTRUCT { + ?book knora-api:isMainResource true . + ?book incunabula:title ?title . + +} WHERE { + ?book a incunabula:book . + ?book incunabula:title ?title . +} +` + ); + }); + + it('should reset the form', () => { + + const resetBtn = hostCompDe.query(By.css('button.reset')); + const textarea = hostCompDe.query(By.css('textarea.textarea-field-content')); + + const resetEle = resetBtn.nativeElement; + const textareaEle = textarea.nativeElement; + + // delete textarea content displayed by default to make a change + textareaEle.value = ''; + expect(textareaEle.value).toBe(''); + + resetEle.click(); + + testHostFixture.detectChanges(); + + // reset the textarea content + expect(textareaEle.value).toBe( + `PREFIX knora-api: +PREFIX incunabula: + +CONSTRUCT { + ?book knora-api:isMainResource true . + ?book incunabula:title ?title . + +} WHERE { + ?book a incunabula:book . + ?book incunabula:title ?title . +} +` + ); + }); + + it('should register the query in the params service', () => { + const expectedGravsearch = + `PREFIX knora-api: +PREFIX incunabula: + +CONSTRUCT { + ?book knora-api:isMainResource true . + ?book incunabula:title ?title . + +} WHERE { + ?book a incunabula:book . + ?book incunabula:title ?title . +} + + OFFSET 0 + ` + ; + const submitBtn = hostCompDe.query(By.css('button[type="submit"]')); + const submitBtnEle = submitBtn.nativeElement; + + submitBtnEle.click(); + testHostFixture.detectChanges(); + + expect(searchParamsServiceSpy.changeSearchParamsMsg).toHaveBeenCalledTimes(1); + expect(advancedSearchParams).toBeDefined(); + expect(advancedSearchParams.generateGravsearch(0)).toEqual(expectedGravsearch); + }); + + it('should emit the Gravsearch query', () => { + const expectedGravsearch = + `PREFIX knora-api: +PREFIX incunabula: + +CONSTRUCT { + ?book knora-api:isMainResource true . + ?book incunabula:title ?title . + +} WHERE { + ?book a incunabula:book . + ?book incunabula:title ?title . +} + + OFFSET 0 + `; + + const submitBtn = hostCompDe.query(By.css('button[type="submit"]')); + const submitBtnEle = submitBtn.nativeElement; + + expect(testHostComponent.gravsearchQ).toBeUndefined(); + + submitBtnEle.click(); + testHostFixture.detectChanges(); + + expect(testHostComponent.gravsearchQ).toBeDefined(); + expect(testHostComponent.gravsearchQ.query).toEqual(expectedGravsearch); + expect(testHostComponent.gravsearchQ.mode).toEqual('gravsearch'); + + }); + + it('should not return an invalid query', () => { + expect(testHostComponent.expertSearch.expertSearchForm.valid).toBeTruthy(); + + const textarea = hostCompDe.query(By.css('textarea.textarea-field-content')); + const textareaEle = textarea.nativeElement; + + expect(textareaEle.value).toBe( + `PREFIX knora-api: +PREFIX incunabula: + +CONSTRUCT { + ?book knora-api:isMainResource true . + ?book incunabula:title ?title . + +} WHERE { + ?book a incunabula:book . + ?book incunabula:title ?title . +} +` + ); + + textareaEle.value = + `PREFIX knora-api: +PREFIX incunabula: + +CONSTRUCT { + ?book knora-api:isMainResource true . + ?book incunabula:title ?title . + +} WHERE { + ?book a incunabula:book . + ?book incunabula:title ?title . +} + +OFFSET 0 +`; + + textareaEle.dispatchEvent(new Event('input')); + testHostFixture.detectChanges(); + + expect(testHostComponent.expertSearch.expertSearchForm.valid).toBeFalsy(); + + const submitForm = testHostComponent.expertSearch.submitQuery(); + + expect(submitForm).toBeFalsy(); + }); + +}); diff --git a/src/app/workspace/search/expert-search/expert-search.component.ts b/src/app/workspace/search/expert-search/expert-search.component.ts new file mode 100644 index 0000000000..4a4afa37d5 --- /dev/null +++ b/src/app/workspace/search/expert-search/expert-search.component.ts @@ -0,0 +1,141 @@ +import { Component, EventEmitter, Inject, OnInit, Output } from '@angular/core'; +import { AbstractControl, FormBuilder, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms'; +import { KnoraApiConfig } from '@dasch-swiss/dsp-js'; +import { DspApiConfigToken } from 'src/app/main/declarations/dsp-api-tokens'; +import { SearchParams } from '../../results/list-view/list-view.component'; +import { AdvancedSearchParams, AdvancedSearchParamsService } from '../services/advanced-search-params.service'; + +/** + * @ignore + * Validator checking that the query does not contain a certain term, here OFFSET + * + * @param {RegExp} termRe + */ +export function forbiddenTermValidator(termRe: RegExp): ValidatorFn { + return (control: AbstractControl): { [key: string]: any } | null => { + const forbidden = termRe.test(control.value); + return forbidden ? { forbiddenName: { value: control.value } } : null; + }; +} + +@Component({ + selector: 'app-expert-search', + templateUrl: './expert-search.component.html', + styleUrls: ['./expert-search.component.scss'] +}) +export class ExpertSearchComponent implements OnInit { + + /** + * the data event emitter of type SearchParams + * + * @param search + */ + @Output() search = new EventEmitter(); + + expertSearchForm: FormGroup; + queryFormControl: FormControl; + + iriBaseUrl = this._getIriBaseUrl(); + + defaultGravsearchQuery = + `PREFIX knora-api: +PREFIX incunabula: <${this.iriBaseUrl}/ontology/0803/incunabula/v2#> + +CONSTRUCT { + ?book knora-api:isMainResource true . + ?book incunabula:title ?title . + +} WHERE { + ?book a incunabula:book . + ?book incunabula:title ?title . +} +`; + + constructor( + @Inject(DspApiConfigToken) private _dspApiConfig: KnoraApiConfig, + private _searchParamsService: AdvancedSearchParamsService, + private _fb: FormBuilder + ) { } + + ngOnInit(): void { + // initialize the form with predefined Gravsearch query as example. + this.queryFormControl = new FormControl(this.defaultGravsearchQuery); + + this.expertSearchForm = this._fb.group({ + gravsearchquery: [ + this.defaultGravsearchQuery, + [ + Validators.required, + forbiddenTermValidator(/OFFSET/i) + ] + ] + }); + } + + /** + * @ignore + * Reset the form to the initial state. + */ + resetForm() { + this.expertSearchForm.reset({ gravsearchquery: this.defaultGravsearchQuery }); + } + + /** + * @ignore + * Send the gravsearch query to the result view by emitting the gravsearch as an output. + */ + submitQuery() { + const gravsearch = this._generateGravsearch(0); + + if (gravsearch) { + this.search.emit({ + query: gravsearch, + mode: 'gravsearch' + }); + } + } + + /** + * @ignore + * Generate the whole gravsearch query matching the query given by the form. + */ + private _generateGravsearch(offset: number = 0): string { + const query = this.expertSearchForm.controls['gravsearchquery'].value; + + // offset component of the Gravsearch query + const offsetTemplate = ` + OFFSET ${offset} + `; + + // function that generates the same Gravsearch query with the given offset + const generateGravsearchWithCustomOffset = ( + localOffset: number + ): string => { + const offsetCustomTemplate = ` + OFFSET ${localOffset} + `; + + return query + offsetCustomTemplate; + }; + + if (offset === 0) { + // store the function so another Gravsearch query can be created with an increased offset + this._searchParamsService.changeSearchParamsMsg(new AdvancedSearchParams(generateGravsearchWithCustomOffset)); + } + return query + offsetTemplate; + } + + /** + * get the IRI base url without configured api protocol. + * The protocol in this case is always http + * TODO: move to DSP-JS-Lib similar to `get ApiUrl` + */ + private _getIriBaseUrl(): string { + return ( + ('http://' + this._dspApiConfig.apiHost) + + (this._dspApiConfig.apiPort !== null ? ':' + this._dspApiConfig.apiPort : '') + + this._dspApiConfig.apiPath + ); + } + +} diff --git a/src/app/workspace/search/fulltext-search/fulltext-search.component.html b/src/app/workspace/search/fulltext-search/fulltext-search.component.html new file mode 100644 index 0000000000..8985a3c621 --- /dev/null +++ b/src/app/workspace/search/fulltext-search/fulltext-search.component.html @@ -0,0 +1,143 @@ + + + + + + + +
+
+ +
+ +

+
+ {{item.projectLabel}} +
+
+ {{item.query}} +
+

+ +
+
+
+ +
+ + +
+
+
+ +
+ + + +
+ + + +
+
+ + +
+ + + + + +
+
+
+ +
+ + +
+ + + +
+
+ +
+ +

+
+ {{item.projectLabel}} +
+
+ {{item.query}} +
+

+ +
+
+
+
+
+ + +
+
+ +
+
diff --git a/src/app/workspace/search/fulltext-search/fulltext-search.component.scss b/src/app/workspace/search/fulltext-search/fulltext-search.component.scss new file mode 100644 index 0000000000..c4d6f43eab --- /dev/null +++ b/src/app/workspace/search/fulltext-search/fulltext-search.component.scss @@ -0,0 +1,288 @@ +@import "../../../../assets/style/search"; + +// +// general css (applied on desktop/tablet versions) +// + +.app-fulltext-search { + border-radius: $border-radius; + height: 40px; + position: relative; + z-index: 100; + background-color: $bright; + + &.active { + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5); + } + + .app-project-filter-button { + font-size: inherit; + overflow: hidden; + text-overflow: ellipsis; + border-top-left-radius: $border-radius; + border-top-right-radius: 0; + border-bottom-left-radius: $border-radius; + border-bottom-right-radius: 0; + margin: 1px; + } +} + +.app-fulltext-search-field { + background-color: $bright; + border-radius: $border-radius; + display: inline-flex; + flex: 1; + position: relative; + z-index: 10; + width: 100%; + margin: 1px; + + &.with-project-filter { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + + .app-fulltext-search-input { + border-style: none; + font-size: 14pt; + height: 38px; + padding-left: 12px; + width: 100%; + + &:active, + &:focus { + outline: none; + } + } + + .app-fulltext-search-button { + background-color: #ffffff; + } + + .suffix { + border-top-left-radius: 0; + border-top-right-radius: $border-radius; + border-bottom-right-radius: $border-radius; + border-bottom-left-radius: 0; + margin: 1px 0 1px -3px; + } + .prefix { + border-top-left-radius: $border-radius; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-bottom-left-radius: $border-radius; + margin: 1px 0 1px 3px; + } + .prefix, + .suffix { + border-style: none; + color: rgba($dark, 0.4); + cursor: pointer; + height: 38px; + outline: none; + position: relative; + width: 39px; + + &.disabled { + cursor: auto; + } + + &:active { + color: rgb(81, 81, 81); + } + } +} + +.app-search-menu { + height: 100%; + + .app-menu-content { + display: block; + + .app-previous-search-list { + padding-bottom: 8px; + + .mat-list-item { + .app-previous-search-item { + cursor: pointer; + padding: 12px !important; + } + + &:hover { + background-color: $grey; + + .mat-icon { + display: inline-block; + } + } + + .mat-icon { + display: none; + } + + .app-previous-search-item { + display: inherit; + + .app-project-filter-label { + overflow: hidden; + text-overflow: ellipsis; + width: $project-filter-width; + + &.not-empty { + &::before { + content: "["; + } + &::after { + content: "]"; + } + } + } + + .app-previous-search-query { + font-weight: bold; + overflow: hidden; + text-overflow: ellipsis; + + &.fix-width { + width: calc(100% - #{$project-filter-width}); + } + } + } + } + } + } +} + +.app-project-filter-menu { + width: $project-filter-width; + .mat-menu-item { + text-transform: capitalize; + } +} + +.app-project-filter-button { + height: 38px !important; + display: block; + text-align: left; + + .placeholder { + margin: 0; + padding: 0; + font-size: x-small; + } + + .label, + .icon { + display: inline; + position: relative; + } + + .label { + top: -12px; + font-size: smaller; + text-transform: capitalize; + } + + .icon { + top: -6px; + float: right; + } +} + + +// tablet and desktop devices: hide phone-version classes +@media (min-width: map-get($grid-breakpoints, phone)) { + .desktop-only { + display: flex; + } + .mobile-only { + display: none; + } +} + +// small mobile device: phone +@media (max-width: map-get($grid-breakpoints, phone)) { + .desktop-only { + display: none; + } + .mobile-only { + display: block; + } + + .app-fulltext-search-mobile-panel { + height: 100% !important; + width: 100% !important; + background-color: rgba(218, 218, 218, 0.96); + z-index: 100; + position: fixed; + display: block; + border-radius: 0; + top: 0; + left: 0; + + .app-fulltext-search-field, + .app-project-filter { + background: none; + margin: 12px auto; + max-width: $search-width; + width: 90%; + display: flex; + + .app-project-filter-button { + width: 100%; + } + } + + .app-fulltext-search-input, + .app-fulltext-search-button { + border-radius: $border-radius; + } + + .app-fulltext-search-input { + margin-right: 12px; + } + .app-fulltext-search-button { + width: 96px; + border: .8px solid $grey; + } + } + + .app-project-filter-menu { + width: 100vw; + } + + .app-search-menu { + height: auto; + box-shadow: none; + background-color: transparent; + position: absolute; + top: 128px; + height: calc(100vh - 128px); + width: 100%; + padding: 0; + + .app-menu-content { + text-align: left; + overflow-y: auto; + } + .app-menu-action { + position: absolute; + bottom: 12px; + } + } + + // TODO: do not use ng-deep anymore!!! + ::ng-deep .cdk-overlay-pane { + .mat-menu-panel { + box-shadow: none; + max-width: 100% !important; + } + + .mat-select-panel-wrap { + margin-top: 20%; + .mat-select-panel { + max-height: 100% !important; + } + } + } +} diff --git a/src/app/workspace/search/fulltext-search/fulltext-search.component.spec.ts b/src/app/workspace/search/fulltext-search/fulltext-search.component.spec.ts new file mode 100644 index 0000000000..521315589b --- /dev/null +++ b/src/app/workspace/search/fulltext-search/fulltext-search.component.spec.ts @@ -0,0 +1,332 @@ +import { OverlayModule } from '@angular/cdk/overlay'; +import { Component, OnInit, ViewChild } from '@angular/core'; +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatListModule } from '@angular/material/list'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { MockProjects, ProjectsEndpointAdmin } from '@dasch-swiss/dsp-js'; +import { of } from 'rxjs/internal/observable/of'; +import { DspApiConnectionToken } from 'src/app/main/declarations/dsp-api-tokens'; +import { SortingService } from 'src/app/main/services/sorting.service'; +import { FulltextSearchComponent } from './fulltext-search.component'; + +/** + * test host component to simulate parent component. + */ +@Component({ + selector: 'app-host-component', + template: ` + + + ` +}) +class TestHostFulltextSearchComponent implements OnInit { + + @ViewChild('fulltextSearch') fulltextSearch: FulltextSearchComponent; + + sortingService: SortingService = new SortingService(); + + projectfilter?: boolean = true; + + limitToProject?: string; + + ngOnInit() { + } + +} + +interface PrevSearchItem { + projectIri?: string; + projectLabel?: string; + query: string; +} + +describe('FulltextSearchComponent', () => { + let testHostComponent: TestHostFulltextSearchComponent; + let testHostFixture: ComponentFixture; + let fulltextSearchComponentDe; + let hostCompDe; + let dspConnSpy; + let prevSearchArray: PrevSearchItem[]; + + beforeEach(waitForAsync(() => { + + dspConnSpy = { + admin: { + projectsEndpoint: jasmine.createSpyObj('projectsEndpoint', ['getProjects', 'getProjectByIri']) + } + }; + + TestBed.configureTestingModule({ + declarations: [ + FulltextSearchComponent, + TestHostFulltextSearchComponent + ], + imports: [ + OverlayModule, + FormsModule, + BrowserAnimationsModule, + MatMenuModule, + MatInputModule, + MatTooltipModule, + MatIconModule, + MatDividerModule, + MatListModule, + MatSnackBarModule + ], + providers: [ + { + provide: DspApiConnectionToken, + useValue: dspConnSpy + } + ] + }) + .compileComponents(); + + })); + + beforeEach(() => { + + // mock local storage + let store = {}; + + spyOn(localStorage, 'getItem').and.callFake((key: string): string => store[key] || null); + spyOn(localStorage, 'removeItem').and.callFake((key: string): void => { + delete store[key]; + }); + spyOn(localStorage, 'setItem').and.callFake((key: string, value: string): string => store[key] = value as string); + spyOn(localStorage, 'clear').and.callFake(() => { + store = {}; + }); + }); + + beforeEach(() => { + + // initiate the local storage prevSearch + prevSearchArray = [ + { projectIri: 'http://rdfh.ch/projects/0803', projectLabel: 'incunabula', query: 'one thing' }, + { projectIri: 'http://rdfh.ch/projects/0803', projectLabel: 'incunabula', query: 'two things' }, + { projectIri: 'http://rdfh.ch/projects/0801', projectLabel: 'anything', query: 'hello world' } + ]; + + localStorage.setItem('prevSearch', JSON.stringify(prevSearchArray)); + expect(localStorage.getItem('prevSearch')).toBeDefined(); + }); + + beforeEach(() => { + + // mock getProjects response + const valuesSpy = TestBed.inject(DspApiConnectionToken); + + (valuesSpy.admin.projectsEndpoint as jasmine.SpyObj).getProjects.and.callFake( + () => { + const projects = MockProjects.mockProjects(); + return of(projects); + } + ); + + testHostFixture = TestBed.createComponent(TestHostFulltextSearchComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + hostCompDe = testHostFixture.debugElement; + fulltextSearchComponentDe = hostCompDe.query(By.directive(FulltextSearchComponent)); + }); + + + it('should create', () => { + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.fulltextSearch).toBeTruthy(); + }); + + it('should get projects on init', () => { + const projSpy = TestBed.inject(DspApiConnectionToken); + expect(projSpy.admin.projectsEndpoint.getProjects).toHaveBeenCalledTimes(1); + + expect(testHostComponent.fulltextSearch.projects).toBeDefined(); + expect(testHostComponent.fulltextSearch.projects.length).toEqual(8); + expect(testHostComponent.fulltextSearch.projectfilter).toEqual(true); + expect(testHostComponent.fulltextSearch.projectLabel).toEqual('All projects'); + }); + + describe('perform or reset search', () => { + + let searchInputNativeEl; + + beforeEach(() => { + + searchInputNativeEl = fulltextSearchComponentDe.query(By.css('input.app-fulltext-search-input')).nativeElement; + }); + + it('should do a search', () => { + const searchBtn = fulltextSearchComponentDe.query(By.css('button.app-fulltext-search-button')); + expect(searchBtn).toBeDefined(); + expect(searchInputNativeEl).toBeDefined(); + + searchInputNativeEl.value = 'new thing'; + searchInputNativeEl.dispatchEvent(new Event('input')); + + // click on the search button and trigger the method doSearch() + searchBtn.triggerEventHandler('click', null); + testHostFixture.detectChanges(); + + // check the local storage state + expect(testHostComponent.fulltextSearch.searchQuery).toEqual('new thing'); + const newPrevSearchArray: PrevSearchItem[] = [ + { projectIri: 'http://rdfh.ch/projects/0803', projectLabel: 'incunabula', query: 'one thing' }, + { projectIri: 'http://rdfh.ch/projects/0803', projectLabel: 'incunabula', query: 'two things' }, + { projectIri: 'http://rdfh.ch/projects/0801', projectLabel: 'anything', query: 'hello world' }, + { query: 'new thing' } + ]; + expect(localStorage.getItem('prevSearch')).toEqual(JSON.stringify(newPrevSearchArray)); + + }); + + it('should perform a search with a previous item', () => { + // click in the search input to open the search panel + expect(searchInputNativeEl).toBeDefined(); + searchInputNativeEl.click(); + testHostFixture.detectChanges(); + + const searchMenuPanel = fulltextSearchComponentDe.query(By.css('div.app-search-menu')).nativeElement; + expect(searchMenuPanel).toBeDefined(); + + const prevSearchItem = fulltextSearchComponentDe.query(By.css('div.app-previous-search-query')).nativeElement; + prevSearchItem.click(); + testHostFixture.detectChanges(); + + expect(testHostComponent.fulltextSearch.searchQuery).toEqual('hello world'); + expect(testHostComponent.fulltextSearch.projectIri).toEqual('http://rdfh.ch/projects/0801'); + expect(testHostComponent.fulltextSearch.projectLabel).toEqual('anything'); + + }); + + it('should perform a search with a previous item - solution 2', () => { + testHostComponent.fulltextSearch.doPrevSearch(prevSearchArray[0]); + + expect(testHostComponent.fulltextSearch.searchQuery).toEqual('one thing'); + expect(testHostComponent.fulltextSearch.projectIri).toEqual('http://rdfh.ch/projects/0803'); + expect(testHostComponent.fulltextSearch.projectLabel).toEqual('incunabula'); + + }); + + }); + + describe('clear the search list', () => { + + let searchInputNativeEl; + + beforeEach(() => { + searchInputNativeEl = fulltextSearchComponentDe.query(By.css('input.app-fulltext-search-input')).nativeElement; + }); + + it('should remove one item of the search list - solution 1', () => { + // click in the search input to open the search panel + expect(searchInputNativeEl).toBeDefined(); + searchInputNativeEl.click(); + testHostFixture.detectChanges(); + + // click on the close icon to remove the item of the list + const closeItemBtn = fulltextSearchComponentDe.query(By.css('mat-icon.mat-list-close-icon')).nativeElement; + expect(closeItemBtn).toBeDefined(); + closeItemBtn.click(); + testHostFixture.detectChanges(); + + const newPrevSearchArray: PrevSearchItem[] = [ + { projectIri: 'http://rdfh.ch/projects/0803', projectLabel: 'incunabula', query: 'two things' }, + { projectIri: 'http://rdfh.ch/projects/0803', projectLabel: 'incunabula', query: 'one thing' } + ]; + expect(localStorage.getItem('prevSearch')).toEqual(JSON.stringify(newPrevSearchArray)); + }); + + it('should remove one item of the search list - solution 2', () => { + // prevSearch is set correctly at this stage: + testHostComponent.fulltextSearch.resetPrevSearch(prevSearchArray[2]); + + const newPrevSearchArray: PrevSearchItem[] = [ + { projectIri: 'http://rdfh.ch/projects/0803', projectLabel: 'incunabula', query: 'one thing' }, + { projectIri: 'http://rdfh.ch/projects/0803', projectLabel: 'incunabula', query: 'two things' } + ]; + expect(localStorage.getItem('prevSearch')).toEqual(JSON.stringify(newPrevSearchArray)); + }); + + it('should clear the search list - solution 1', () => { + // click in the search input to open the search panel + expect(searchInputNativeEl).toBeDefined(); + searchInputNativeEl.click(); + testHostFixture.detectChanges(); + + // click on the Clear List button to erase the search list + const clearListBtn = fulltextSearchComponentDe.query(By.css('button.clear-list-btn')).nativeElement; + expect(clearListBtn).toBeDefined(); + clearListBtn.click(); + testHostFixture.detectChanges(); + + expect(localStorage.getItem('prevSearch')).toBe(null); + }); + + it('should clear the search list - solution 2', () => { + testHostComponent.fulltextSearch.resetPrevSearch(); + + expect(localStorage.getItem('prevSearch')).toBe(null); + }); + + }); + + describe('project menu panel', () => { + + it('should get a menu panel with the list of projects', () => { + const projButtonDe = fulltextSearchComponentDe.query(By.css('button.app-project-filter-button')); + const projButtonNe = projButtonDe.nativeElement; + + expect(projButtonNe).toBeDefined(); + + const projBtnLabelDe = projButtonDe.query(By.css('button > p.label')); + const projBtnLabelNe = projBtnLabelDe.nativeElement; + + expect(projBtnLabelNe.innerHTML).toEqual('All projects'); + + projButtonNe.click(); + testHostFixture.detectChanges(); + + const projMenuPanelDe = projButtonDe.query(By.css('div.mat-menu-panel')); + const projMenuPanelNe = projMenuPanelDe.nativeElement; + + expect(projMenuPanelNe).toBeDefined(); + }); + + it('should select one project in the menu panel', () => { + + const projButtonDe = fulltextSearchComponentDe.query(By.css('button.app-project-filter-button')); + const projButtonNe = projButtonDe.nativeElement; + + const projBtnLabelDe = projButtonDe.query(By.css('button > p.label')); + const projBtnLabelNe = projBtnLabelDe.nativeElement; + + projButtonNe.click(); + testHostFixture.detectChanges(); + + const projMenuPanelDe = projButtonDe.query(By.css('div.mat-menu-panel')); + const projPanelItemDe = projMenuPanelDe.query(By.css('.project-item')); + const projPanelItemNe = projPanelItemDe.nativeElement; + + projPanelItemNe.click(); + testHostFixture.detectChanges(); + + expect(projBtnLabelNe.innerHTML).toEqual('anything'); + expect(testHostComponent.fulltextSearch.projectIri).toEqual('http://rdfh.ch/projects/0001'); + expect(testHostComponent.fulltextSearch.projectLabel).toEqual('anything'); + }); + + }); + +}); diff --git a/src/app/workspace/search/fulltext-search/fulltext-search.component.ts b/src/app/workspace/search/fulltext-search/fulltext-search.component.ts new file mode 100644 index 0000000000..d0ff308390 --- /dev/null +++ b/src/app/workspace/search/fulltext-search/fulltext-search.component.ts @@ -0,0 +1,428 @@ +import { ConnectionPositionPair, Overlay, OverlayConfig, OverlayRef, PositionStrategy } from '@angular/cdk/overlay'; +import { TemplatePortal } from '@angular/cdk/portal'; +import { + Component, + ElementRef, + EventEmitter, + Inject, + Input, + OnChanges, + OnInit, + Output, + TemplateRef, + ViewChild, + ViewContainerRef +} from '@angular/core'; +import { MatMenuTrigger } from '@angular/material/menu'; +import { + ApiResponseData, + ApiResponseError, + Constants, + KnoraApiConnection, + ProjectResponse, + ProjectsResponse, + ReadProject +} from '@dasch-swiss/dsp-js'; +import { DspApiConnectionToken } from 'src/app/main/declarations/dsp-api-tokens'; +import { NotificationService } from 'src/app/main/services/notification.service'; +import { SortingService } from 'src/app/main/services/sorting.service'; +import { SearchParams } from '../../results/list-view/list-view.component'; + +export interface PrevSearchItem { + projectIri?: string; + projectLabel?: string; + query: string; +} + +const resolvedPromise = Promise.resolve(null); + +@Component({ + selector: 'app-fulltext-search', + templateUrl: './fulltext-search.component.html', + styleUrls: ['./fulltext-search.component.scss'] +}) +export class FulltextSearchComponent implements OnInit, OnChanges { + + /** + * + * @param [projectfilter] If true it shows the selection + * of projects to filter by one of them + */ + @Input() projectfilter?: boolean = false; + + /** + * @deprecated Use `limitToProject` instead + * + * @param [filterbyproject] If the full-text search should be + * filtered by one project, you can define it with project iri. + */ + @Input() filterbyproject?: string; + + /** + * filter ontologies in advanced search or query in fulltext search by specified project IRI + * + * @param limitToProject + */ + @Input() limitToProject?: string; + + + /** + * emits selected project in case of projectfilter + */ + @Output() limitToProjectChange = new EventEmitter(); + + /** + * the data event emitter of type SearchParams + * + * @param search + */ + @Output() search = new EventEmitter(); + + @ViewChild('fulltextSearchPanel', { static: false }) searchPanel: ElementRef; + + @ViewChild('fulltextSearchInput', { static: false }) searchInput: ElementRef; + @ViewChild('fulltextSearchInputMobile', { static: false }) searchInputMobile: ElementRef; + + @ViewChild('fulltextSearchMenu', { static: false }) searchMenu: TemplateRef; + + @ViewChild('btnToSelectProject', { static: false }) selectProject: MatMenuTrigger; + + // search query + searchQuery: string; + + // previous search = full-text search history + prevSearch: PrevSearchItem[]; + + // list of projects, in case of filterproject is true + projects: ReadProject[]; + + // selected project, in case of limitToProject and/or projectfilter is true + project: ReadProject; + + defaultProjectLabel = 'All projects'; + + projectLabel: string = this.defaultProjectLabel; + + projectIri: string; + + // in case of an (api) error + error: any; + + // is search panel focused? + searchPanelFocus = false; + + // overlay reference + overlayRef: OverlayRef; + + // do not show the following projects: default system projects from knora + doNotDisplay: string[] = [ + Constants.SystemProjectIRI, + Constants.DefaultSharedOntologyIRI + ]; + + // toggle phone panel + displayPhonePanel = false; + + constructor( + @Inject(DspApiConnectionToken) private _dspApiConnection: KnoraApiConnection, + private _notification: NotificationService, + private _sortingService: SortingService, + private _overlay: Overlay, + private _viewContainerRef: ViewContainerRef + ) { } + + ngOnInit(): void { + // filterbyproject is set as deprecated. To avoid breaking changes we still support it + if (this.filterbyproject) { + this.limitToProject = this.filterbyproject; + } + + // initialise prevSearch + const prevSearchOption = JSON.parse(localStorage.getItem('prevSearch')); + if (prevSearchOption !== null) { + this.prevSearch = prevSearchOption; + } else { + this.prevSearch = []; + } + + if (this.limitToProject) { + this.getProject(this.limitToProject); + } + + if (this.projectfilter) { + this.getAllProjects(); + } + } + + ngOnChanges() { + // resource classes have been reinitialized + // reset form + resolvedPromise.then(() => { + + if (localStorage.getItem('currentProject') !== null) { + this.setProject( + JSON.parse(localStorage.getItem('currentProject')) + ); + } + + }); + } + + /** + * get all public projects from DSP-API + */ + getAllProjects(): void { + this._dspApiConnection.admin.projectsEndpoint.getProjects().subscribe( + (response: ApiResponseData) => { + this.projects = response.body.projects; + // this.loadSystem = false; + if (localStorage.getItem('currentProject') !== null) { + this.project = JSON.parse( + localStorage.getItem('currentProject') + ); + } + this.projects = this._sortingService.keySortByAlphabetical(response.body.projects, 'shortname'); + }, + (error: ApiResponseError) => { + this._notification.openSnackBar(error); + this.error = error; + } + ); + } + + /** + * get project by IRI + * @param id Project Id + */ + getProject(id: string): void { + this._dspApiConnection.admin.projectsEndpoint.getProjectByIri(id).subscribe( + (project: ApiResponseData) => { + this.setProject(project.body.project); + }, + (error: ApiResponseError) => { + this._notification.openSnackBar(error); + } + ); + } + + /** + * set current project and switch focus to input field. + * @params project + */ + setProject(project?: ReadProject): void { + if (!project) { + // set default project: all + this.projectLabel = this.defaultProjectLabel; + this.projectIri = undefined; + this.limitToProject = undefined; + this.limitToProjectChange.emit(this.limitToProject); + localStorage.removeItem('currentProject'); + } else { + // set current project shortname and id + this.projectLabel = project.shortname; + this.projectIri = project.id; + this.limitToProject = project.id; + this.limitToProjectChange.emit(this.limitToProject); + localStorage.setItem('currentProject', JSON.stringify(project)); + } + } + + /** + * open the search panel with backdrop + */ + openPanelWithBackdrop(): void { + const config = new OverlayConfig({ + hasBackdrop: true, + backdropClass: 'cdk-overlay-transparent-backdrop', + positionStrategy: this.getOverlayPosition(), + scrollStrategy: this._overlay.scrollStrategies.block() + }); + + this.overlayRef = this._overlay.create(config); + this.overlayRef.attach(new TemplatePortal(this.searchMenu, this._viewContainerRef)); + this.overlayRef.backdropClick().subscribe(() => { + this.searchPanelFocus = false; + if (this.overlayRef) { + this.overlayRef.detach(); + } + }); + } + + /** + * return the correct overlay position + */ + getOverlayPosition(): PositionStrategy { + const positions = [ + new ConnectionPositionPair({ originX: 'start', originY: 'bottom' }, { overlayX: 'start', overlayY: 'top' }), + new ConnectionPositionPair({ originX: 'start', originY: 'top' }, { overlayX: 'start', overlayY: 'bottom' }) + ]; + + // tslint:disable-next-line: max-line-length + const overlayPosition = this._overlay.position().flexibleConnectedTo(this.searchPanel).withPositions(positions).withLockedPosition(false); + + return overlayPosition; + } + + /** + * send the search query to parent and store the new query in the local storage + * to have a search history list + */ + doSearch(): void { + + if (this.searchQuery !== undefined && this.searchQuery !== null) { + + // push the search query into the local storage prevSearch array (previous search) + // to have a list of recent search requests + let existingPrevSearch: PrevSearchItem[] = JSON.parse( + localStorage.getItem('prevSearch') + ); + if (existingPrevSearch === null) { + existingPrevSearch = []; + } + let i = 0; + for (const entry of existingPrevSearch) { + // remove entry, if exists already + if (this.searchQuery === entry.query && this.projectIri === entry.projectIri) { + existingPrevSearch.splice(i, 1); + } + i++; + } + + // a search value is expected to have at least length of 3 + if (this.searchQuery.length > 2) { + let currentQuery: PrevSearchItem = { + query: this.searchQuery + }; + + if (this.projectIri) { + currentQuery = { + projectIri: this.projectIri, + projectLabel: this.projectLabel, + query: this.searchQuery + }; + } + + existingPrevSearch.push(currentQuery); + + localStorage.setItem( + 'prevSearch', + JSON.stringify(existingPrevSearch) + ); + } + + this.emitSearchParams(); + } + + this.resetSearch(); + + if (this.overlayRef) { + this.overlayRef.detach(); + } + + } + + /** + * clear the whole list of search + */ + resetSearch(): void { + if (this.displayPhonePanel) { + this.searchInputMobile.nativeElement.blur(); + this.togglePhonePanel(); + } else { + this.searchPanelFocus = false; + this.searchInput.nativeElement.blur(); + } + if (this.overlayRef) { + this.overlayRef.detach(); + } + + } + + /** + * set the focus on the search panel + */ + setFocus(): void { + if (localStorage.getItem('prevSearch') !== null) { + this.prevSearch = this._sortingService.reverseArray(JSON.parse(localStorage.getItem('prevSearch'))); + } else { + this.prevSearch = []; + } + + if(!this.displayPhonePanel) { + this.searchPanelFocus = true; + this.openPanelWithBackdrop(); + } + } + + /** + * perform a search with a selected search item from the search history + * @params prevSearch + */ + doPrevSearch(prevSearch: PrevSearchItem): void { + this.searchQuery = prevSearch.query; + + if (prevSearch.projectIri !== undefined) { + this.projectIri = prevSearch.projectIri; + this.projectLabel = prevSearch.projectLabel; + } else { + this.projectIri = undefined; + this.projectLabel = this.defaultProjectLabel; + } + this.emitSearchParams(); + + this.resetSearch(); + + if (this.overlayRef) { + this.overlayRef.detach(); + } + } + + /** + * remove one search item from the search history + * @params prevSearchItem + */ + resetPrevSearch(prevSearchItem?: PrevSearchItem): void { + if (prevSearchItem) { + // delete only this item with the name + const i: number = this.prevSearch.indexOf(prevSearchItem); + this.prevSearch.splice(i, 1); + localStorage.setItem('prevSearch', JSON.stringify(this.prevSearch)); + } else { + // delete the whole "previous search" array + localStorage.removeItem('prevSearch'); + } + + if (localStorage.getItem('prevSearch') === null) { + this.prevSearch = []; + } + } + + /** + * change the focus on the search input field + */ + changeFocus() { + this.selectProject.closeMenu(); + this.searchInput.nativeElement.focus(); + this.setFocus(); + } + + emitSearchParams() { + const searchParams: SearchParams = { + query: this.searchQuery, + mode: 'fulltext' + }; + + if (this.projectIri !== undefined) { + searchParams.filter = { + limitToProject: this.projectIri + }; + } + + this.search.emit(searchParams); + } + + togglePhonePanel() { + this.displayPhonePanel = !this.displayPhonePanel; + } + +} diff --git a/src/app/workspace/search/search-panel/search-panel.component.html b/src/app/workspace/search/search-panel/search-panel.component.html new file mode 100644 index 0000000000..65961ed911 --- /dev/null +++ b/src/app/workspace/search/search-panel/search-panel.component.html @@ -0,0 +1,42 @@ +
+ + + + + +
+ + + + + +
+ +
+ + + + + diff --git a/src/app/workspace/search/search-panel/search-panel.component.scss b/src/app/workspace/search/search-panel/search-panel.component.scss new file mode 100644 index 0000000000..f573247d41 --- /dev/null +++ b/src/app/workspace/search/search-panel/search-panel.component.scss @@ -0,0 +1,34 @@ +@import "../../../../assets/style/search"; + +:host { + display: flex; + height: 72px; + + .advanced-expert-buttons { + display: table; + height: 20px; + margin-left: auto; + + button { + margin-left: 3px; + justify-self: end; + line-height: 18px !important; + font-size: 0.8em; + border-top-left-radius: 0; + border-top-right-radius: 0; + border-bottom-right-radius: 4px; + border-bottom-left-radius: 4px; + + &.active { + background-color: $black-12-opacity; + } + } + } + + // responsive style: do not display expert and advanced search on phones + @media (max-width: map-get($grid-breakpoints, phone)) { + .advanced-expert-buttons { + display: none; + } + } +} diff --git a/src/app/workspace/search/search-panel/search-panel.component.spec.ts b/src/app/workspace/search/search-panel/search-panel.component.spec.ts new file mode 100644 index 0000000000..2fb81d0129 --- /dev/null +++ b/src/app/workspace/search/search-panel/search-panel.component.spec.ts @@ -0,0 +1,75 @@ +import { OverlayModule } from '@angular/cdk/overlay'; +import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'; +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatMenuModule } from '@angular/material/menu'; +import { SearchPanelComponent } from './search-panel.component'; + +/** + * test host component to simulate child component, here fulltext-search. + */ +@Component({ + selector: 'app-fulltext-search' +}) +class TestFulltextSearchComponent implements OnInit { + + @Input() projectfilter?: boolean = false; + @Input() limitToProject?: string; + @Input() show: boolean; + @Output() showState = new EventEmitter(); + + ngOnInit() { } +} + +/** + * test host component to simulate parent component with a search panel. + */ +@Component({ + template: ` + + ` +}) +class TestHostComponent { + + @ViewChild('searchPanelView') searchPanelComponent: SearchPanelComponent; + + projectfilter = true; + advanced = false; + expert = false; + +} + +describe('SearchPanelComponent', () => { + let testHostComponent: TestHostComponent; + let testHostFixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + SearchPanelComponent, + TestHostComponent, + TestFulltextSearchComponent + ], + imports: [ + OverlayModule, + MatMenuModule + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + }); + + it('should create an instance', () => { + expect(testHostComponent.searchPanelComponent).toBeTruthy(); + }); + +}); diff --git a/src/app/workspace/search/search-panel/search-panel.component.ts b/src/app/workspace/search/search-panel/search-panel.component.ts new file mode 100644 index 0000000000..ce0bff51a0 --- /dev/null +++ b/src/app/workspace/search/search-panel/search-panel.component.ts @@ -0,0 +1,143 @@ +import { ConnectionPositionPair, Overlay, OverlayConfig, OverlayRef, PositionStrategy } from '@angular/cdk/overlay'; +import { TemplatePortal } from '@angular/cdk/portal'; +import { + Component, + ElementRef, + EventEmitter, + Input, OnInit, + Output, + TemplateRef, + ViewChild, + ViewContainerRef +} from '@angular/core'; +import { SearchParams } from '../../results/list-view/list-view.component'; + +@Component({ + selector: 'app-search-panel', + templateUrl: './search-panel.component.html', + styleUrls: ['./search-panel.component.scss'] +}) +export class SearchPanelComponent implements OnInit { + + /** + * @param [projectfilter] If true it shows the selection of projects to filter by one of them + * Default value: false + */ + @Input() projectfilter?: boolean = false; + + /** + * @deprecated Use `limitToProject` instead + * + * @param [filterbyproject] If your full-text search should be filtered by one project, you can define it with project + * iri in the parameter filterbyproject. + */ + @Input() filterbyproject?: string; + + /** + * filter ontologies in advanced search or query in fulltext search by specified project IRI + * + * @param limitToProject + */ + @Input() limitToProject?: string; + + /** + * @param [advanced] Adds the extended / advanced search to the panel + * Default value: false + */ + @Input() advanced?: boolean = false; + + /** + * @param [expert] Adds the expert search / gravsearch editor to the panel + * Default value: false + */ + @Input() expert?: boolean = false; + + /** + * the data event emitter of type SearchParams + * + * @param search + */ + @Output() search = new EventEmitter(); + + @ViewChild('fullSearchPanel', { static: false }) searchPanel: ElementRef; + + @ViewChild('searchMenu', { static: false }) searchMenu: TemplateRef; + + // overlay reference + overlayRef: OverlayRef; + + // show advanced or expert search + showAdvanced: boolean; + showExpert: boolean; + + constructor( + private _overlay: Overlay, + private _viewContainerRef: ViewContainerRef + ) { } + + ngOnInit() { + // filterbyproject is set as deprecated. To avoid breaking changes we still support the parameter + if (this.filterbyproject) { + this.limitToProject = this.filterbyproject; + } + } + + openPanelWithBackdrop(type: string) { + + this.showAdvanced = (type === 'advanced'); + this.showExpert = (type === 'expert'); + + const config = new OverlayConfig({ + hasBackdrop: true, + backdropClass: 'cdk-overlay-transparent-backdrop', + positionStrategy: this.getOverlayPosition(), + scrollStrategy: this._overlay.scrollStrategies.block() + }); + + this.overlayRef = this._overlay.create(config); + this.overlayRef.attach(new TemplatePortal(this.searchMenu, this._viewContainerRef)); + this.overlayRef.backdropClick().subscribe(() => { + this.showAdvanced = false; + this.showExpert = false; + this.overlayRef.detach(); + }); + } + + getOverlayPosition(): PositionStrategy { + const positions = [ + new ConnectionPositionPair({ originX: 'start', originY: 'bottom' }, { overlayX: 'start', overlayY: 'top' }), + new ConnectionPositionPair({ originX: 'start', originY: 'top' }, { overlayX: 'start', overlayY: 'bottom' }) + ]; + + // tslint:disable-next-line: max-line-length + const overlayPosition = this._overlay.position().flexibleConnectedTo(this.searchPanel).withPositions(positions).withLockedPosition(false); + + return overlayPosition; + } + + updateLimitToProject(id: string) { + this.limitToProject = id; + } + + /** + * emit the search parameters + * + * @param data + */ + emitSearch(data: any) { + this.search.emit(data); + this.closeMenu(); + } + + /** + * close the search menu + */ + closeMenu(): void { + this.showAdvanced = false; + this.showExpert = false; + if (this.overlayRef) { + this.overlayRef.detach(); + } + } + +} diff --git a/src/app/search/services/advanced-search-params.service.spec.ts b/src/app/workspace/search/services/advanced-search-params.service.spec.ts similarity index 100% rename from src/app/search/services/advanced-search-params.service.spec.ts rename to src/app/workspace/search/services/advanced-search-params.service.spec.ts diff --git a/src/app/search/services/advanced-search-params.service.ts b/src/app/workspace/search/services/advanced-search-params.service.ts similarity index 100% rename from src/app/search/services/advanced-search-params.service.ts rename to src/app/workspace/search/services/advanced-search-params.service.ts diff --git a/src/app/workspace/search/services/gravsearch-generation.service.spec.ts b/src/app/workspace/search/services/gravsearch-generation.service.spec.ts new file mode 100644 index 0000000000..ad862272fe --- /dev/null +++ b/src/app/workspace/search/services/gravsearch-generation.service.spec.ts @@ -0,0 +1,1150 @@ +/* eslint-disable max-len */ +import { GravsearchGenerationService } from './gravsearch-generation.service'; +import { AdvancedSearchParams, AdvancedSearchParamsService } from './advanced-search-params.service'; +import { TestBed } from '@angular/core/testing'; +import { MockOntology, ResourcePropertyDefinition } from '@dasch-swiss/dsp-js'; +import { + ComparisonOperatorAndValue, + Equals, GreaterThan, GreaterThanEquals, IRI, LessThan, LessThanEquals, Like, LinkedResource, Match, NotEquals, + PropertyWithValue, + ValueLiteral +} from '../advanced-search/resource-and-property-selection/search-select-property/specify-property-value/operator'; + + +describe('GravsearchGenerationService', () => { + let gravSearchGenerationServ: GravsearchGenerationService; + let searchParamsServiceSpy: jasmine.SpyObj; // see https://angular.io/guide/testing#angular-testbed + let advancedSearchParams: AdvancedSearchParams; + + beforeEach(() => { + const spy = jasmine.createSpyObj('SearchParamsService', ['changeSearchParamsMsg']); + + TestBed.configureTestingModule({ + providers: [ + { provide: AdvancedSearchParamsService, useValue: spy } + ] + }); + + gravSearchGenerationServ = TestBed.inject(GravsearchGenerationService); + searchParamsServiceSpy = TestBed.inject(AdvancedSearchParamsService) as jasmine.SpyObj; + searchParamsServiceSpy.changeSearchParamsMsg.and.callFake((searchParams: AdvancedSearchParams) => { + advancedSearchParams = searchParams; + }); + }); + + it('should be created', () => { + expect(gravSearchGenerationServ).toBeTruthy(); + }); + + it('should create a Gravsearch query string with an integer property matching a value', () => { + + const prop = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2').properties['http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger'] as ResourcePropertyDefinition; + + const value = new ComparisonOperatorAndValue(new Equals(), new ValueLiteral('1', 'http://www.w3.org/2001/XMLSchema#integer')); + + const propWithVal = new PropertyWithValue(prop, value, false); + + const gravsearch = gravSearchGenerationServ.createGravsearchQuery([propWithVal], 'http://0.0.0.0:3333/ontology/0001/anything/v2#Thing', 0); + + const expectedGravsearch = ` +PREFIX knora-api: +CONSTRUCT { + +?mainRes knora-api:isMainResource true . + +?mainRes ?propVal0 . + +} WHERE { + +?mainRes a knora-api:Resource . + +?mainRes a . + + +?mainRes ?propVal0 . + + + +?propVal0 ?propVal0Literal +FILTER(?propVal0Literal = "1"^^) + + +} + +OFFSET 0 +`; + + expect(gravsearch).toEqual(expectedGravsearch); + + expect(searchParamsServiceSpy.changeSearchParamsMsg).toHaveBeenCalledTimes(1); + expect(advancedSearchParams).toBeDefined(); + expect(advancedSearchParams.generateGravsearch(0)).toEqual(expectedGravsearch); + + }); + + it('should create a Gravsearch query string with a decimal property matching a value', () => { + + const prop = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2').properties['http://0.0.0.0:3333/ontology/0001/anything/v2#hasDecimal'] as ResourcePropertyDefinition; + + const value = new ComparisonOperatorAndValue(new Equals(), new ValueLiteral('1.1', 'http://www.w3.org/2001/XMLSchema#decimal')); + + const propWithVal = new PropertyWithValue(prop, value, false); + + const gravsearch = gravSearchGenerationServ.createGravsearchQuery([propWithVal], 'http://0.0.0.0:3333/ontology/0001/anything/v2#Thing', 0); + + const expectedGravsearch = ` +PREFIX knora-api: +CONSTRUCT { + +?mainRes knora-api:isMainResource true . + +?mainRes ?propVal0 . + +} WHERE { + +?mainRes a knora-api:Resource . + +?mainRes a . + + +?mainRes ?propVal0 . + + + +?propVal0 ?propVal0Literal +FILTER(?propVal0Literal = "1.1"^^) + + +} + +OFFSET 0 +`; + + expect(gravsearch).toEqual(expectedGravsearch); + + expect(searchParamsServiceSpy.changeSearchParamsMsg).toHaveBeenCalledTimes(1); + + }); + + it('should create a Gravsearch query string with a boolean property matching a value', () => { + + const prop = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2').properties['http://0.0.0.0:3333/ontology/0001/anything/v2#hasBoolean'] as ResourcePropertyDefinition; + + const value = new ComparisonOperatorAndValue(new Equals(), new ValueLiteral('true', 'http://www.w3.org/2001/XMLSchema#boolean')); + + const propWithVal = new PropertyWithValue(prop, value, false); + + const gravsearch = gravSearchGenerationServ.createGravsearchQuery([propWithVal], 'http://0.0.0.0:3333/ontology/0001/anything/v2#Thing', 0); + + const expectedGravsearch = ` +PREFIX knora-api: +CONSTRUCT { + +?mainRes knora-api:isMainResource true . + +?mainRes ?propVal0 . + +} WHERE { + +?mainRes a knora-api:Resource . + +?mainRes a . + + +?mainRes ?propVal0 . + + + +?propVal0 ?propVal0Literal +FILTER(?propVal0Literal = "true"^^) + + +} + +OFFSET 0 +`; + + expect(gravsearch).toEqual(expectedGravsearch); + + expect(searchParamsServiceSpy.changeSearchParamsMsg).toHaveBeenCalledTimes(1); + + }); + + it('should create a Gravsearch query string with a text property that equals a value', () => { + + const prop = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2').properties['http://0.0.0.0:3333/ontology/0001/anything/v2#hasText'] as ResourcePropertyDefinition; + + const value = new ComparisonOperatorAndValue(new Equals(), new ValueLiteral('test', 'http://www.w3.org/2001/XMLSchema#string')); + + const propWithVal = new PropertyWithValue(prop, value, false); + + const gravsearch = gravSearchGenerationServ.createGravsearchQuery([propWithVal], 'http://0.0.0.0:3333/ontology/0001/anything/v2#Thing', 0); + + const expectedGravsearch = ` +PREFIX knora-api: +CONSTRUCT { + +?mainRes knora-api:isMainResource true . + +?mainRes ?propVal0 . + +} WHERE { + +?mainRes a knora-api:Resource . + +?mainRes a . + + +?mainRes ?propVal0 . + + + +?propVal0 ?propVal0Literal +FILTER(?propVal0Literal = "test"^^) + + +} + +OFFSET 0 +`; + + expect(gravsearch).toEqual(expectedGravsearch); + + expect(searchParamsServiceSpy.changeSearchParamsMsg).toHaveBeenCalledTimes(1); + + }); + + it('should create a Gravsearch query string with a text property matching a value using LIKE', () => { + + const prop = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2').properties['http://0.0.0.0:3333/ontology/0001/anything/v2#hasText'] as ResourcePropertyDefinition; + + const value = new ComparisonOperatorAndValue(new Like(), new ValueLiteral('test', 'http://www.w3.org/2001/XMLSchema#string')); + + const propWithVal = new PropertyWithValue(prop, value, false); + + const gravsearch = gravSearchGenerationServ.createGravsearchQuery([propWithVal], 'http://0.0.0.0:3333/ontology/0001/anything/v2#Thing', 0); + + const expectedGravsearch = ` +PREFIX knora-api: +CONSTRUCT { + +?mainRes knora-api:isMainResource true . + +?mainRes ?propVal0 . + +} WHERE { + +?mainRes a knora-api:Resource . + +?mainRes a . + + +?mainRes ?propVal0 . + + + +?propVal0 ?propVal0Literal +FILTER regex(?propVal0Literal, "test"^^, "i") + + +} + +OFFSET 0 +`; + + expect(gravsearch).toEqual(expectedGravsearch); + + expect(searchParamsServiceSpy.changeSearchParamsMsg).toHaveBeenCalledTimes(1); + + }); + + it('should create a Gravsearch query string with a text property matching a value using MATCH', () => { + + const prop = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2').properties['http://0.0.0.0:3333/ontology/0001/anything/v2#hasText'] as ResourcePropertyDefinition; + + const value = new ComparisonOperatorAndValue(new Match(), new ValueLiteral('test', 'http://www.w3.org/2001/XMLSchema#string')); + + const propWithVal = new PropertyWithValue(prop, value, false); + + const gravsearch = gravSearchGenerationServ.createGravsearchQuery([propWithVal], 'http://0.0.0.0:3333/ontology/0001/anything/v2#Thing', 0); + + const expectedGravsearch = ` +PREFIX knora-api: +CONSTRUCT { + +?mainRes knora-api:isMainResource true . + +?mainRes ?propVal0 . + +} WHERE { + +?mainRes a knora-api:Resource . + +?mainRes a . + + +?mainRes ?propVal0 . + + + +FILTER (?propVal0, "test"^^) + + +} + +OFFSET 0 +`; + + expect(gravsearch).toEqual(expectedGravsearch); + + expect(searchParamsServiceSpy.changeSearchParamsMsg).toHaveBeenCalledTimes(1); + + }); + + it('should create a Gravsearch query string with a URI property matching a value', () => { + + const prop = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2').properties['http://0.0.0.0:3333/ontology/0001/anything/v2#hasUri'] as ResourcePropertyDefinition; + + const value = new ComparisonOperatorAndValue(new Equals(), new ValueLiteral('http://www.google.ch', 'http://www.w3.org/2001/XMLSchema#anyURI')); + + const propWithVal = new PropertyWithValue(prop, value, false); + + const gravsearch = gravSearchGenerationServ.createGravsearchQuery([propWithVal], 'http://0.0.0.0:3333/ontology/0001/anything/v2#Thing', 0); + + const expectedGravsearch = ` +PREFIX knora-api: +CONSTRUCT { + +?mainRes knora-api:isMainResource true . + +?mainRes ?propVal0 . + +} WHERE { + +?mainRes a knora-api:Resource . + +?mainRes a . + + +?mainRes ?propVal0 . + + + +?propVal0 ?propVal0Literal +FILTER(?propVal0Literal = "http://www.google.ch"^^) + + +} + +OFFSET 0 +`; + + expect(gravsearch).toEqual(expectedGravsearch); + + expect(searchParamsServiceSpy.changeSearchParamsMsg).toHaveBeenCalledTimes(1); + + }); + + it('should create a Gravsearch query string with a date property matching a value', () => { + + const prop = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2').properties['http://0.0.0.0:3333/ontology/0001/anything/v2#hasDate'] as ResourcePropertyDefinition; + + const value = new ComparisonOperatorAndValue(new Equals(), new ValueLiteral('GREGORIAN:2019-02-02', 'http://api.knora.org/ontology/knora-api/simple/v2#Date')); + + const propWithVal = new PropertyWithValue(prop, value, false); + + const gravsearch = gravSearchGenerationServ.createGravsearchQuery([propWithVal], 'http://0.0.0.0:3333/ontology/0001/anything/v2#Thing', 0); + + const expectedGravsearch = ` +PREFIX knora-api: +CONSTRUCT { + +?mainRes knora-api:isMainResource true . + +?mainRes ?propVal0 . + +} WHERE { + +?mainRes a knora-api:Resource . + +?mainRes a . + + +?mainRes ?propVal0 . + + + +FILTER(knora-api:toSimpleDate(?propVal0) = "GREGORIAN:2019-02-02"^^) + + +} + +OFFSET 0 +`; + + expect(gravsearch).toEqual(expectedGravsearch); + + expect(searchParamsServiceSpy.changeSearchParamsMsg).toHaveBeenCalledTimes(1); + + }); + + it('should create a Gravsearch query string with a list node property matching a node', () => { + + const prop = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2').properties['http://0.0.0.0:3333/ontology/0001/anything/v2#hasListItem'] as ResourcePropertyDefinition; + + const value = new ComparisonOperatorAndValue(new Equals(), new IRI('http://rdfh.ch/lists/0001/treeList')); + + const propWithVal = new PropertyWithValue(prop, value, false); + + const gravsearch = gravSearchGenerationServ.createGravsearchQuery([propWithVal], 'http://0.0.0.0:3333/ontology/0001/anything/v2#Thing', 0); + + const expectedGravsearch = ` +PREFIX knora-api: +CONSTRUCT { + +?mainRes knora-api:isMainResource true . + +?mainRes ?propVal0 . + +} WHERE { + +?mainRes a knora-api:Resource . + +?mainRes a . + + +?mainRes ?propVal0 . + + + +?propVal0 + + + +} + +OFFSET 0 +`; + + expect(gravsearch).toEqual(expectedGravsearch); + + expect(searchParamsServiceSpy.changeSearchParamsMsg).toHaveBeenCalledTimes(1); + + }); + + it('should create a Gravsearch query string with a list node property not matching a node', () => { + + const prop = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2').properties['http://0.0.0.0:3333/ontology/0001/anything/v2#hasListItem'] as ResourcePropertyDefinition; + + const value = new ComparisonOperatorAndValue(new NotEquals(), new IRI('http://rdfh.ch/lists/0001/treeList01')); + + const propWithVal = new PropertyWithValue(prop, value, false); + + const gravsearch = gravSearchGenerationServ.createGravsearchQuery([propWithVal], 'http://0.0.0.0:3333/ontology/0001/anything/v2#Thing', 0); + + const expectedGravsearch = ` +PREFIX knora-api: +CONSTRUCT { + +?mainRes knora-api:isMainResource true . + +?mainRes ?propVal0 . + +} WHERE { + +?mainRes a knora-api:Resource . + +?mainRes a . + + +?mainRes ?propVal0 . + + + +FILTER NOT EXISTS { + ?propVal0 + + } + + +} + +OFFSET 0 +`; + + expect(gravsearch).toEqual(expectedGravsearch); + + expect(searchParamsServiceSpy.changeSearchParamsMsg).toHaveBeenCalledTimes(1); + + }); + + it('should create a Gravsearch query string with restriction to a resource class using offset 0', () => { + + const gravsearch = gravSearchGenerationServ.createGravsearchQuery([], 'http://0.0.0.0:3333/ontology/0001/anything/v2#Thing', 0); + + const expectedGravsearch = ` +PREFIX knora-api: +CONSTRUCT { + +?mainRes knora-api:isMainResource true . + + + +} WHERE { + +?mainRes a knora-api:Resource . + +?mainRes a . + + + +} + +OFFSET 0 +`; + + expect(gravsearch).toEqual(expectedGravsearch); + + expect(searchParamsServiceSpy.changeSearchParamsMsg).toHaveBeenCalledTimes(1); + + }); + + it('should create a Gravsearch query string with restriction to a resource class using offset 1', () => { + + const gravsearch = gravSearchGenerationServ.createGravsearchQuery([], 'http://0.0.0.0:3333/ontology/0001/anything/v2#Thing', 1); + + const expectedGravsearch = ` +PREFIX knora-api: +CONSTRUCT { + +?mainRes knora-api:isMainResource true . + + + +} WHERE { + +?mainRes a knora-api:Resource . + +?mainRes a . + + + +} + +OFFSET 1 +`; + + expect(gravsearch).toEqual(expectedGravsearch); + + expect(searchParamsServiceSpy.changeSearchParamsMsg).toHaveBeenCalledTimes(0); + + }); + + it('should create a Gravsearch query string with a text property matching a value', () => { + + const prop = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2').properties['http://0.0.0.0:3333/ontology/0001/anything/v2#hasText'] as ResourcePropertyDefinition; + + const value = new ComparisonOperatorAndValue(new Like(), new ValueLiteral('test', 'http://www.w3.org/2001/XMLSchema#string')); + + const propWithVal = new PropertyWithValue(prop, value, false); + + const gravsearch = gravSearchGenerationServ.createGravsearchQuery([propWithVal], undefined, 0); + + const expectedGravsearch = ` +PREFIX knora-api: +CONSTRUCT { + +?mainRes knora-api:isMainResource true . + +?mainRes ?propVal0 . + +} WHERE { + +?mainRes a knora-api:Resource . + + + + +?mainRes ?propVal0 . + + + +?propVal0 ?propVal0Literal +FILTER regex(?propVal0Literal, "test"^^, "i") + + +} + +OFFSET 0 +`; + + expect(gravsearch).toEqual(expectedGravsearch); + + expect(searchParamsServiceSpy.changeSearchParamsMsg).toHaveBeenCalledTimes(1); + + }); + + it('should create a Gravsearch query string with a date property matching a value', () => { + + const prop = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2').properties['http://0.0.0.0:3333/ontology/0001/anything/v2#hasDate'] as ResourcePropertyDefinition; + + const value = new ComparisonOperatorAndValue(new GreaterThanEquals(), new ValueLiteral('GREGORIAN:2018-06-12', 'http://api.knora.org/ontology/knora-api/simple/v2#Date')); + + const propWithVal = new PropertyWithValue(prop, value, false); + + const gravsearch = gravSearchGenerationServ.createGravsearchQuery([propWithVal], undefined, 0); + + const expectedGravsearch = ` +PREFIX knora-api: +CONSTRUCT { + +?mainRes knora-api:isMainResource true . + +?mainRes ?propVal0 . + +} WHERE { + +?mainRes a knora-api:Resource . + + + + +?mainRes ?propVal0 . + + + +FILTER(knora-api:toSimpleDate(?propVal0) >= "GREGORIAN:2018-06-12"^^) + + +} + +OFFSET 0 +`; + + expect(gravsearch).toEqual(expectedGravsearch); + + expect(searchParamsServiceSpy.changeSearchParamsMsg).toHaveBeenCalledTimes(1); + + + }); + + it('should create a Gravsearch query string with a decimal property matching a value', () => { + + const prop = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2').properties['http://0.0.0.0:3333/ontology/0001/anything/v2#hasDecimal'] as ResourcePropertyDefinition; + + + const value = new ComparisonOperatorAndValue(new LessThanEquals(), new ValueLiteral('1.5', 'http://www.w3.org/2001/XMLSchema#decimal')); + + const propWithVal = new PropertyWithValue(prop, value, false); + + const gravsearch = gravSearchGenerationServ.createGravsearchQuery([propWithVal], undefined, 0); + + const expectedGravsearch = ` +PREFIX knora-api: +CONSTRUCT { + +?mainRes knora-api:isMainResource true . + +?mainRes ?propVal0 . + +} WHERE { + +?mainRes a knora-api:Resource . + + + + +?mainRes ?propVal0 . + + + +?propVal0 ?propVal0Literal +FILTER(?propVal0Literal <= "1.5"^^) + + +} + +OFFSET 0 +`; + + expect(gravsearch).toEqual(expectedGravsearch); + + expect(searchParamsServiceSpy.changeSearchParamsMsg).toHaveBeenCalledTimes(1); + + + }); + + it('should create a Gravsearch query string with an integer property matching a value', () => { + + const prop = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2').properties['http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger'] as ResourcePropertyDefinition; + + + const value = new ComparisonOperatorAndValue(new LessThan(), new ValueLiteral('1', 'http://www.w3.org/2001/XMLSchema#integer')); + + const propWithVal = new PropertyWithValue(prop, value, false); + + const gravsearch = gravSearchGenerationServ.createGravsearchQuery([propWithVal], undefined, 0); + + const expectedGravsearch = ` +PREFIX knora-api: +CONSTRUCT { + +?mainRes knora-api:isMainResource true . + +?mainRes ?propVal0 . + +} WHERE { + +?mainRes a knora-api:Resource . + + + + +?mainRes ?propVal0 . + + + +?propVal0 ?propVal0Literal +FILTER(?propVal0Literal < "1"^^) + + +} + +OFFSET 0 +`; + + expect(gravsearch).toEqual(expectedGravsearch); + + expect(searchParamsServiceSpy.changeSearchParamsMsg).toHaveBeenCalledTimes(1); + + }); + + it('should create a Gravsearch query string with a linking property not matching a specific resource', () => { + + const prop = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2').properties['http://0.0.0.0:3333/ontology/0001/anything/v2#hasOtherThing'] as ResourcePropertyDefinition; + + const value = new ComparisonOperatorAndValue(new NotEquals(), new IRI('http://rdfh.ch/0001/thing_with_richtext_with_markup')); + + const propWithVal = new PropertyWithValue(prop, value, false); + + const gravsearch = gravSearchGenerationServ.createGravsearchQuery([propWithVal], undefined, 0); + + const expectedGravsearch = ` +PREFIX knora-api: +CONSTRUCT { + +?mainRes knora-api:isMainResource true . + + + +} WHERE { + +?mainRes a knora-api:Resource . + + + +FILTER NOT EXISTS { +?mainRes . + + +} + + + +} + +OFFSET 0 +`; + + expect(gravsearch).toEqual(expectedGravsearch); + + expect(searchParamsServiceSpy.changeSearchParamsMsg).toHaveBeenCalledTimes(1); + + }); + + it('should create a Gravsearch query string with an integer property matching a value and use it as a sort criterion', () => { + + const prop = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2').properties['http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger'] as ResourcePropertyDefinition; + + const value = new ComparisonOperatorAndValue(new LessThan(), new ValueLiteral('1', 'http://www.w3.org/2001/XMLSchema#integer')); + + const propWithVal = new PropertyWithValue(prop, value, true); + + const gravsearch = gravSearchGenerationServ.createGravsearchQuery([propWithVal], undefined, 0); + + const expectedGravsearch = ` +PREFIX knora-api: +CONSTRUCT { + +?mainRes knora-api:isMainResource true . + +?mainRes ?propVal0 . + +} WHERE { + +?mainRes a knora-api:Resource . + + + + +?mainRes ?propVal0 . + + + +?propVal0 ?propVal0Literal +FILTER(?propVal0Literal < "1"^^) + + +} + +ORDER BY ?propVal0 + +OFFSET 0 +`; + + expect(gravsearch).toEqual(expectedGravsearch); + + expect(searchParamsServiceSpy.changeSearchParamsMsg).toHaveBeenCalledTimes(1); + + }); + + it('should create a Gravsearch query string with a decimal property matching a value and use it as a sort criterion', () => { + + const prop = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2').properties['http://0.0.0.0:3333/ontology/0001/anything/v2#hasDecimal'] as ResourcePropertyDefinition; + + const value = new ComparisonOperatorAndValue(new Equals(), new ValueLiteral('2.1', 'http://www.w3.org/2001/XMLSchema#decimal')); + + const propWithVal = new PropertyWithValue(prop, value, true); + + const gravsearch = gravSearchGenerationServ.createGravsearchQuery([propWithVal], 'http://0.0.0.0:3333/ontology/0001/anything/v2#Thing', 0); + + const expectedGravsearch = ` +PREFIX knora-api: +CONSTRUCT { + +?mainRes knora-api:isMainResource true . + +?mainRes ?propVal0 . + +} WHERE { + +?mainRes a knora-api:Resource . + +?mainRes a . + + +?mainRes ?propVal0 . + + + +?propVal0 ?propVal0Literal +FILTER(?propVal0Literal = "2.1"^^) + + +} + +ORDER BY ?propVal0 + +OFFSET 0 +`; + + expect(gravsearch).toEqual(expectedGravsearch); + + expect(searchParamsServiceSpy.changeSearchParamsMsg).toHaveBeenCalledTimes(1); + + }); + + it('should create a Gravsearch query string with a date property matching a value and use it as a sort criterion', () => { + + const prop = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2').properties['http://0.0.0.0:3333/ontology/0001/anything/v2#hasDate'] as ResourcePropertyDefinition; + + const value = new ComparisonOperatorAndValue(new Equals(), new ValueLiteral('GREGORIAN:2019-02-02', 'http://api.knora.org/ontology/knora-api/simple/v2#Date')); + + const propWithVal = new PropertyWithValue(prop, value, true); + + const gravsearch = gravSearchGenerationServ.createGravsearchQuery([propWithVal], 'http://0.0.0.0:3333/ontology/0001/anything/v2#Thing', 0); + + const expectedGravsearch = ` +PREFIX knora-api: +CONSTRUCT { + +?mainRes knora-api:isMainResource true . + +?mainRes ?propVal0 . + +} WHERE { + +?mainRes a knora-api:Resource . + +?mainRes a . + + +?mainRes ?propVal0 . + + + +FILTER(knora-api:toSimpleDate(?propVal0) = "GREGORIAN:2019-02-02"^^) + + +} + +ORDER BY ?propVal0 + +OFFSET 0 +`; + + expect(gravsearch).toEqual(expectedGravsearch); + + expect(searchParamsServiceSpy.changeSearchParamsMsg).toHaveBeenCalledTimes(1); + + }); + + it('should create a Gravsearch query string with a date property matching a value used as a sort criterion and an decimal property also used as a sort criterion', () => { + + const prop1 = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2').properties['http://0.0.0.0:3333/ontology/0001/anything/v2#hasDate'] as ResourcePropertyDefinition; + + const value1 = new ComparisonOperatorAndValue(new LessThan(), new ValueLiteral('GREGORIAN:2019-02-02', 'http://api.knora.org/ontology/knora-api/simple/v2#Date')); + + const propWithVal1 = new PropertyWithValue(prop1, value1, true); + + const prop2 = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2').properties['http://0.0.0.0:3333/ontology/0001/anything/v2#hasDecimal'] as ResourcePropertyDefinition; + + const value2 = new ComparisonOperatorAndValue(new GreaterThan(), new ValueLiteral('0.1', 'http://www.w3.org/2001/XMLSchema#decimal')); + + const propWithVal2 = new PropertyWithValue(prop2, value2, true); + + const gravsearch = gravSearchGenerationServ.createGravsearchQuery([propWithVal1, propWithVal2], 'http://0.0.0.0:3333/ontology/0001/anything/v2#Thing', 0); + + const expectedGravsearch = ` +PREFIX knora-api: +CONSTRUCT { + +?mainRes knora-api:isMainResource true . + +?mainRes ?propVal0 . +?mainRes ?propVal1 . + +} WHERE { + +?mainRes a knora-api:Resource . + +?mainRes a . + + +?mainRes ?propVal0 . + + + +FILTER(knora-api:toSimpleDate(?propVal0) < "GREGORIAN:2019-02-02"^^) + +?mainRes ?propVal1 . + + + +?propVal1 ?propVal1Literal +FILTER(?propVal1Literal > "0.1"^^) + + +} + +ORDER BY ?propVal0 ?propVal1 + +OFFSET 0 +`; + + expect(gravsearch).toEqual(expectedGravsearch); + + expect(searchParamsServiceSpy.changeSearchParamsMsg).toHaveBeenCalledTimes(1); + + }); + + it('should create a Gravsearch query string with a linking property matching a resource', () => { + + const prop = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2').properties['http://0.0.0.0:3333/ontology/0001/anything/v2#hasOtherThing'] as ResourcePropertyDefinition; + + + const value = new ComparisonOperatorAndValue(new Equals(), new IRI('http://rdfh.ch/0001/0C-0L1kORryKzJAJxxRyRQ')); + + const propWithVal = new PropertyWithValue(prop, value, false); + + const gravsearch = gravSearchGenerationServ.createGravsearchQuery([propWithVal], undefined, 0); + + const expectedGravsearch = ` +PREFIX knora-api: +CONSTRUCT { + +?mainRes knora-api:isMainResource true . + +?mainRes . + +} WHERE { + +?mainRes a knora-api:Resource . + + + + +?mainRes . + + + + + + +} + +OFFSET 0 +`; + + expect(gravsearch).toEqual(expectedGravsearch); + + expect(searchParamsServiceSpy.changeSearchParamsMsg).toHaveBeenCalledTimes(1); + + }); + + it('search for a specified linked resource specified by one prop', () => { + + const anythingOnto = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2'); + + const hasOtherThingProp = anythingOnto.properties['http://0.0.0.0:3333/ontology/0001/anything/v2#hasOtherThing']; + + const linkedResValue = new ComparisonOperatorAndValue(new GreaterThan(), new ValueLiteral('0.5', 'http://www.w3.org/2001/XMLSchema#decimal')); + + const hasDecimal = anythingOnto.properties['http://0.0.0.0:3333/ontology/0001/anything/v2#hasDecimal']; + + const linkedResourceWithVal = new PropertyWithValue(hasDecimal as ResourcePropertyDefinition, linkedResValue, false); + + const linkedResource = new LinkedResource([linkedResourceWithVal], 'http://0.0.0.0:3333/ontology/0001/anything/v2#Thing'); + + const mainResValue = new ComparisonOperatorAndValue(new Match(), linkedResource); + + const mainResPropWithVal = new PropertyWithValue(hasOtherThingProp as ResourcePropertyDefinition, mainResValue, false); + + const gravsearch = gravSearchGenerationServ.createGravsearchQuery([mainResPropWithVal], 'http://0.0.0.0:3333/ontology/0001/anything/v2#Thing', 0); + + const expectedGravsearch = ` +PREFIX knora-api: +CONSTRUCT { + +?mainRes knora-api:isMainResource true . + +?mainRes ?linkedRes00 . +?linkedRes00 ?propVallinkedRes000 . + + + + +} WHERE { + +?mainRes a knora-api:Resource . + +?mainRes a . + + +?mainRes ?linkedRes00 . +?linkedRes00 ?propVallinkedRes000 . + + + + + + + +?linkedRes00 a . +?propVallinkedRes000 ?propVallinkedRes000Literal +FILTER(?propVallinkedRes000Literal > "0.5"^^) + + +} + +OFFSET 0 +`; + + expect(gravsearch).toEqual(expectedGravsearch); + + expect(searchParamsServiceSpy.changeSearchParamsMsg).toHaveBeenCalledTimes(1); + + }); + + it('search for a specified linked resource specified by two props', () => { + + const anythingOnto = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2'); + + const hasOtherThingProp = anythingOnto.properties['http://0.0.0.0:3333/ontology/0001/anything/v2#hasOtherThing']; + + const linkedResDecValue = new ComparisonOperatorAndValue(new GreaterThan(), new ValueLiteral('0.5', 'http://www.w3.org/2001/XMLSchema#decimal')); + + const hasDecimal = anythingOnto.properties['http://0.0.0.0:3333/ontology/0001/anything/v2#hasDecimal']; + + const linkedResourceWithDecVal = new PropertyWithValue(hasDecimal as ResourcePropertyDefinition, linkedResDecValue, false); + + const linkedResIntValue = new ComparisonOperatorAndValue(new Equals(), new ValueLiteral('1', 'http://www.w3.org/2001/XMLSchema#integer')); + + const hasInt = anythingOnto.properties['http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger']; + + const linkedResourceWithIntVal = new PropertyWithValue(hasInt as ResourcePropertyDefinition, linkedResIntValue, false); + + const linkedResource = new LinkedResource([linkedResourceWithDecVal, linkedResourceWithIntVal], 'http://0.0.0.0:3333/ontology/0001/anything/v2#Thing'); + + const mainResValue = new ComparisonOperatorAndValue(new Match(), linkedResource); + + const mainResPropWithVal = new PropertyWithValue(hasOtherThingProp as ResourcePropertyDefinition, mainResValue, false); + + const gravsearch = gravSearchGenerationServ.createGravsearchQuery([mainResPropWithVal], 'http://0.0.0.0:3333/ontology/0001/anything/v2#Thing', 0); + + const expectedGravsearch = ` +PREFIX knora-api: +CONSTRUCT { + +?mainRes knora-api:isMainResource true . + +?mainRes ?linkedRes00 . +?linkedRes00 ?propVallinkedRes000 . + + + +?linkedRes00 ?propVallinkedRes001 . + + + + +} WHERE { + +?mainRes a knora-api:Resource . + +?mainRes a . + + +?mainRes ?linkedRes00 . +?linkedRes00 ?propVallinkedRes000 . + + + +?linkedRes00 ?propVallinkedRes001 . + + + + + + + +?linkedRes00 a . +?propVallinkedRes000 ?propVallinkedRes000Literal +FILTER(?propVallinkedRes000Literal > "0.5"^^) +?propVallinkedRes001 ?propVallinkedRes001Literal +FILTER(?propVallinkedRes001Literal = "1"^^) + + +} + +OFFSET 0 +`; + + expect(gravsearch).toEqual(expectedGravsearch); + + expect(searchParamsServiceSpy.changeSearchParamsMsg).toHaveBeenCalledTimes(1); + + }); + +}); + diff --git a/src/app/workspace/search/services/gravsearch-generation.service.ts b/src/app/workspace/search/services/gravsearch-generation.service.ts new file mode 100644 index 0000000000..e30374094c --- /dev/null +++ b/src/app/workspace/search/services/gravsearch-generation.service.ts @@ -0,0 +1,251 @@ +/** + * + * create Gravsearch queries from provided parameters. + */ +import { Injectable } from '@angular/core'; +import { Constants } from '@dasch-swiss/dsp-js'; +import { AdvancedSearchParams, AdvancedSearchParamsService } from './advanced-search-params.service'; +import { LinkedResource, PropertyWithValue } from '../advanced-search/resource-and-property-selection/search-select-property/specify-property-value/operator'; +import { ComparisonOperatorConstants } from '../advanced-search/resource-and-property-selection/search-select-property/specify-property-value/operator-constants'; + +@Injectable({ + providedIn: 'root' +}) +export class GravsearchGenerationService { + + complexTypeToProp = { + [Constants.IntValue]: Constants.IntValueAsInt, + [Constants.DecimalValue]: Constants.DecimalValueAsDecimal, + [Constants.BooleanValue]: Constants.BooleanValueAsBoolean, + [Constants.TextValue]: Constants.ValueAsString, + [Constants.UriValue]: Constants.UriValueAsUri, + [Constants.ListValue]: Constants.ListValueAsListNode + }; + + // criteria for the order by statement + private _orderByCriteria = []; + + // statements to be returned in query results + private _returnStatements = []; + + constructor(private _searchParamsService: AdvancedSearchParamsService) { + } + + /** + * + * will be replaced by `@knora/api` (github:knora-api-js-lib) + * + * Generates a Gravsearch query from the provided arguments. + * + * @param properties the properties specified by the user. + * @param mainResourceClassOption the class of the main resource, if any. + * @param offset the offset to be used (nth page of results). + */ + createGravsearchQuery(properties: PropertyWithValue[], mainResourceClassOption?: string, offset: number = 0): string { + + // reinit for each Gravsearch query since this service is a singleton + this._orderByCriteria = []; + this._returnStatements = []; + + // class restriction for the resource searched for + let mainResourceClass = ''; + + // if given, create the class restriction for the main resource + if (mainResourceClassOption !== undefined) { + mainResourceClass = `?mainRes a <${mainResourceClassOption}> .`; + } + + // loop over given properties and create statements and filters from them + const props: string[] = properties.map(this._makeHandlePropsMethod('mainRes')).map((statementAndRestriction) => + `${statementAndRestriction[0]} +${statementAndRestriction[1]} +` + ); + + let orderByStatement = ''; + + if (this._orderByCriteria.length > 0) { + orderByStatement = ` +ORDER BY ${this._orderByCriteria.join(' ')} +`; + } + + // template of the Gravsearch query with dynamic components + const gravsearchTemplate = ` +PREFIX knora-api: +CONSTRUCT { + +?mainRes knora-api:isMainResource true . + +${this._returnStatements.join('\n')} + +} WHERE { + +?mainRes a knora-api:Resource . + +${mainResourceClass} + +${props.join('')} + +} +${orderByStatement}`; + + // offset component of the Gravsearch query + const offsetTemplate = ` +OFFSET ${offset} +`; + + // function that generates the same Gravsearch query with the given offset + const generateGravsearchQueryWithCustomOffset = (localOffset: number): string => { + const offsetCustomTemplate = ` +OFFSET ${localOffset} +`; + + return gravsearchTemplate + offsetCustomTemplate; + }; + + if (offset === 0) { + // store the function so another Gravsearch query can be created with an increased offset + this._searchParamsService.changeSearchParamsMsg(new AdvancedSearchParams(generateGravsearchQueryWithCustomOffset)); + } + + + return gravsearchTemplate + offsetTemplate; + + } + + /** + * factory method returning a property handling method. + * + * @param resourceVar Name of the variable identifying the resource. + * @param topLevel Flag indicating if the top level is affected (main resource). + * @param callCounter Inidcates the number of recursive calls of this method. + */ + private _makeHandlePropsMethod(resourceVar: string, topLevel = true, callCounter = 0): (propWithVal: PropertyWithValue, index: number) => [string, string] { + + /** + * converts a [PropertyWithValue] into a tuple of statements and restrictions. + * + * @param propWithVal property with value to be converted. + * @param index index identifying the current prop. + */ + const handleProps = (propWithVal: PropertyWithValue, index: number): [string, string] => { + + let linkedResStatementsAndRestrictions: [string, string][] = []; + // represents the object of a statement + let object; + if (!propWithVal.property.isLinkProperty || propWithVal.valueLiteral.comparisonOperator.getClassName() === 'Exists') { + // it is not a linking property, create a variable for the value (to be used by a subsequent FILTER) + // oR the comparison operator Exists is used in which case we do not need to specify the object any further + if (topLevel) { + object = `?propVal${index}`; + } else { + object = `?propVal${resourceVar}${index}`; + } + } else { + // it is a linking property and the comparison operator is not Exists, + if (!(propWithVal.valueLiteral.value instanceof LinkedResource)) { + // use its IRI + object = propWithVal.valueLiteral.value.toSparql(); + } else { + // specify the resource's properties + const linkedResVarName = `linkedRes${callCounter}${index}`; + + object = `?${linkedResVarName}`; + // recursively call this method to handle the linked resource's properties + linkedResStatementsAndRestrictions = propWithVal.valueLiteral.value.properties.map(this._makeHandlePropsMethod(linkedResVarName, false, callCounter + 1)); + + } + } + + // generate statement + let statement = `?${resourceVar} <${propWithVal.property.id}> ${object} .`; + + if (linkedResStatementsAndRestrictions.length > 0) { + // get statements from two-tuple + statement += linkedResStatementsAndRestrictions + .map(statAndRestr => statAndRestr[0]) + .reduce((acc: string, stat: string) => acc + stat); + } + + // check if it is a linking property that has to be wrapped in a FILTER NOT EXISTS (comparison operator NOT_EQUALS) to negate it + if (propWithVal.property.isLinkProperty && propWithVal.valueLiteral.comparisonOperator.getClassName() === 'NotEquals') { + // do not include statement in results, because the query checks for the absence of this statement + statement = `FILTER NOT EXISTS { +${statement} + + +}`; + } else { + // tODO: check if statement should be returned returned in results (Boolean flag from checkbox) + if (topLevel) { + this._returnStatements.push(statement); + } + statement = ` +${statement} + + +`; + } + + // generate restricting expression (e.g., a FILTER) if comparison operator is not Exists + let restriction = ''; + // only create a FILTER if the comparison operator is not EXISTS and it is not a linking property + if (!propWithVal.property.isLinkProperty && propWithVal.valueLiteral.comparisonOperator.getClassName() !== 'Exists') { + // generate variable for value literal + const propValueLiteral = `${object}Literal`; + + if (propWithVal.valueLiteral.comparisonOperator.getClassName() === 'Like') { + // generate statement to value literal + restriction = `${object} <${this.complexTypeToProp[propWithVal.property.objectType]}> ${propValueLiteral}` + '\n'; + // use regex function for LIKE + restriction += `FILTER regex(${propValueLiteral}, ${propWithVal.valueLiteral.value.toSparql()}, "i")`; + } else if (propWithVal.valueLiteral.comparisonOperator.getClassName() === 'Match') { + // use Gravsearch function for MATCH + restriction += `FILTER <${ComparisonOperatorConstants.MatchFunction}>(${object}, ${propWithVal.valueLiteral.value.toSparql()})`; + } else if (propWithVal.property.objectType === Constants.DateValue) { + // handle date property + restriction = `FILTER(knora-api:toSimpleDate(${object}) ${propWithVal.valueLiteral.comparisonOperator.type} ${propWithVal.valueLiteral.value.toSparql()})`; + } else if (propWithVal.property.objectType === Constants.ListValue) { + // handle list node + restriction = `${object} <${this.complexTypeToProp[propWithVal.property.objectType]}> ${propWithVal.valueLiteral.value.toSparql()}` + '\n'; + // check for comparison operator "not equals" + if (propWithVal.valueLiteral.comparisonOperator.getClassName() === 'NotEquals') { + restriction = `FILTER NOT EXISTS { + ${restriction} + }`; + } + } else { + // generate statement to value literal + restriction = `${object} <${this.complexTypeToProp[propWithVal.property.objectType]}> ${propValueLiteral}` + '\n'; + // generate filter expression + restriction += `FILTER(${propValueLiteral} ${propWithVal.valueLiteral.comparisonOperator.type} ${propWithVal.valueLiteral.value.toSparql()})`; + } + } + + // check for class restriction on linked resource, if any + if ((propWithVal.valueLiteral.value instanceof LinkedResource) && propWithVal.valueLiteral.value.resourceClass !== undefined) { + restriction += `\n${object} a <${propWithVal.valueLiteral.value.resourceClass}> .\n`; + } + + if (linkedResStatementsAndRestrictions.length > 0) { + // get restriction from two-tuple + restriction += linkedResStatementsAndRestrictions + .map(statAndRestr => statAndRestr[1]) + .reduce((acc: string, restr: string) => acc + '\n' + restr); + } + + // check if current value is a sort criterion + if (propWithVal.isSortCriterion) { + this._orderByCriteria.push(object); + } + + return [statement, restriction]; + + }; + + return handleProps; + } + +} + diff --git a/src/assets/style/_search.scss b/src/assets/style/_search.scss new file mode 100644 index 0000000000..9c771b247b --- /dev/null +++ b/src/assets/style/_search.scss @@ -0,0 +1,162 @@ +@import "./config"; +@import "./mixins"; +@import "./responsive"; + +input[type="search"]::-webkit-search-decoration, +input[type="search"]::-webkit-search-cancel-button, +input[type="search"]::-webkit-search-results-button, +input[type="search"]::-webkit-search-results-decoration { + display: none; +} + +input[type="search"] { + -moz-appearance: none; + -webkit-appearance: none; +} + +// sizes for search element +// input field +$search-width: 480px; +$advanced-search-width: 740px; + +// width on smaller devices +$search-width-small-device: 360px; + +// width of project filter +$project-filter-width: 160px; + +// width of project filter on smaller devices +$project-filter-width-small-device: 120px; + + +// shared dropdown menu in fulltext-search and search-panel +.app-search-menu { + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5); + background-color: $bright; + border-radius: $border-radius; + overflow-y: auto; + min-height: 320px; + margin-top: 6px; + padding: 16px; + z-index: -1; + position: relative; + + .app-menu-header { + background-color: rgba(249, 249, 249, 1); + border-top-left-radius: $border-radius; + border-top-right-radius: $border-radius; + display: inline-flex; + height: 48px; + width: 100%; + margin-bottom: 12px; + + .app-menu-title h4 { + margin: 12px 0; + } + } + + .app-menu-action { + position: absolute; + bottom: 0; + width: calc(100% - 32px); + + .center { + display: block; + margin: 12px auto; + } + } +} + +// form elements +$full-input-width: 320; +$gc-large: decimal-floor($full-input-width / 1.618, 0) - 2; +$gc-small: $full-input-width - $gc-large - 4; + +.app-form-content { + width: 100%; + position: relative; + min-height: 320px; + height: 100%; + + .app-form-action { + position: absolute; + bottom: 0; + width: 100%; + display: inline-flex; + } + + .app-form-expert-search { + bottom: 16px; + width: calc(100% - 32px); + display: inline-flex; + } +} + +.small-field { + width: $gc-small + px; +} + +.medium-field { + width: $gc-large + px; +} + +.large-field { + min-width: $full-input-width + px; +} + +.input-icon { + color: $black-60-opacity; +} + +// responsive style: desktop +@media (min-width: map-get($grid-breakpoints, desktop)) { + .app-fulltext-search { + width: $search-width; + + &.with-project-filter { + width: calc(#{$search-width} + #{$project-filter-width}); + } + + .app-project-filter, + .app-project-filter-button { + width: $project-filter-width; + } + } + + .app-search-menu { + width: calc(#{$search-width} - 32px); + &.with-project-filter { + width: calc(#{$search-width} + #{$project-filter-width} - 32px); + } + &.with-advanced-search { + width: calc(#{$advanced-search-width} - 32px); + } + } +} + +// responsive style: tablet +@media (max-width: map-get($grid-breakpoints, desktop)) and (min-width: map-get($grid-breakpoints, phone)) { + + .app-fulltext-search { + width: $search-width-small-device; + + &.with-project-filter { + width: calc(#{$search-width-small-device} + #{$project-filter-width-small-device}); + } + + .app-project-filter, + .app-project-filter-button { + width: $project-filter-width-small-device; + } + } + + .app-search-menu { + width: calc(#{$search-width-small-device} - 32px); + &.with-project-filter { + width: calc(#{$search-width-small-device} + #{$project-filter-width-small-device} - 32px); + } + &.with-advanced-search { + width: 100vw; + } + } +}