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 @@
+
+
+
+
0"
+ #resourceClass
+ [formGroup]="form"
+ [resourceClassDefinitions]="resourceClasses"
+ (resourceClassSelected)="getPropertiesForResourceClass($event)">
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+ 0" [formGroup]="form" >
+
+
+ {{ 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 @@
+
+
+ 0">
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+ 0">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+ 0" [formGroup]="form">
+
+
+ {{ 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 @@
+ 0" [formGroup]="form" >
+
+
+ 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 @@
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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;
+ }
+ }
+}