From 3b52c2ae01596518c0077e39adb5fc9f5eca1901 Mon Sep 17 00:00:00 2001 From: Bertrand Zuchuat Date: Wed, 11 Oct 2023 08:44:05 +0200 Subject: [PATCH] document: implement advanced search Co-Authored-by: Bertrand Zuchuat --- .../admin/src/app/api/document-api.service.ts | 6 + .../app/api/organisation-api.service.spec.ts | 59 ++++ .../src/app/api/organisation-api.service.ts | 34 ++ projects/admin/src/app/app.module.ts | 21 +- .../record/editor/type/field-custom.type.ts | 47 +++ .../record/editor/type/repeat-section.type.ts | 38 ++ .../advanced-search.service.spec.ts | 228 ++++++++++++ .../advanced-search.service.ts | 183 ++++++++++ ...cument-advanced-search-form.component.html | 33 ++ ...document-advanced-search-form.component.ts | 324 ++++++++++++++++++ .../i-advanced-search-config-interface.ts | 65 ++++ .../document-advanced-search.component.ts | 87 +++++ .../document-record-search.component.html | 7 +- .../document-record-search.component.spec.ts | 140 -------- .../document-record-search.component.ts | 32 +- .../app/service/bucket-name.service.spec.ts | 79 +++++ .../src/app/service/bucket-name.service.ts | 53 +++ .../src/lib/service/app-settings.service.ts | 1 + projects/shared/src/tests/user.ts | 3 + 19 files changed, 1293 insertions(+), 147 deletions(-) create mode 100644 projects/admin/src/app/api/organisation-api.service.spec.ts create mode 100644 projects/admin/src/app/api/organisation-api.service.ts create mode 100644 projects/admin/src/app/record/editor/type/field-custom.type.ts create mode 100644 projects/admin/src/app/record/editor/type/repeat-section.type.ts create mode 100644 projects/admin/src/app/record/search-view/document-advanced-search-form/advanced-search.service.spec.ts create mode 100644 projects/admin/src/app/record/search-view/document-advanced-search-form/advanced-search.service.ts create mode 100644 projects/admin/src/app/record/search-view/document-advanced-search-form/document-advanced-search-form.component.html create mode 100644 projects/admin/src/app/record/search-view/document-advanced-search-form/document-advanced-search-form.component.ts create mode 100644 projects/admin/src/app/record/search-view/document-advanced-search-form/i-advanced-search-config-interface.ts create mode 100644 projects/admin/src/app/record/search-view/document-advanced-search.component.ts delete mode 100644 projects/admin/src/app/record/search-view/document-record-search/document-record-search.component.spec.ts create mode 100644 projects/admin/src/app/service/bucket-name.service.spec.ts create mode 100644 projects/admin/src/app/service/bucket-name.service.ts diff --git a/projects/admin/src/app/api/document-api.service.ts b/projects/admin/src/app/api/document-api.service.ts index dc36daa9d..1d452851b 100644 --- a/projects/admin/src/app/api/document-api.service.ts +++ b/projects/admin/src/app/api/document-api.service.ts @@ -22,6 +22,7 @@ import { IAvailability, IAvailabilityService } from '@rero/shared'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { AppConfigService } from '../service/app-config.service'; +import { IAdvancedSearchConfig } from '../record/search-view/document-advanced-search-form/i-advanced-search-config-interface'; @Injectable({ providedIn: 'root' @@ -63,4 +64,9 @@ export class DocumentApiService implements IAvailabilityService { const url = `${this._appConfigService.apiEndpointPrefix}/document/${pid}/availability`; return this._httpClient.get(url); } + + getAdvancedSearchConfig(): Observable { + const url = `${this._appConfigService.apiEndpointPrefix}/document/advanced-search-config`; + return this._httpClient.get(url); + } } diff --git a/projects/admin/src/app/api/organisation-api.service.spec.ts b/projects/admin/src/app/api/organisation-api.service.spec.ts new file mode 100644 index 000000000..cae092585 --- /dev/null +++ b/projects/admin/src/app/api/organisation-api.service.spec.ts @@ -0,0 +1,59 @@ +/* + * RERO ILS UI + * Copyright (C) 2021-2023 RERO + * Copyright (C) 2021-2023 UCLouvain + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import { TestBed } from '@angular/core/testing'; +import { RecordService } from '@rero/ng-core'; +import { of } from 'rxjs'; +import { OrganisationApiService } from './organisation-api.service'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { TranslateModule } from '@ngx-translate/core'; + +describe('OrganisationApiService', () => { + let service: OrganisationApiService; + + const response = { + metadata: { + name: 'Organisation name' + } + } + const recordServiceSpy = jasmine.createSpyObj('RecordService', ['getRecord']); + recordServiceSpy.getRecord.and.returnValue(of(response)); + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + HttpClientTestingModule, + TranslateModule.forRoot() + ], + providers: [ + { provide: RecordService, useValue: recordServiceSpy } + ] + }); + service = TestBed.inject(OrganisationApiService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should return the organization\'s data', () => { + service.getByPid('1').subscribe((data: any) => { + expect(data.name).toEqual(response.metadata.name); + }); + + }); +}); diff --git a/projects/admin/src/app/api/organisation-api.service.ts b/projects/admin/src/app/api/organisation-api.service.ts new file mode 100644 index 000000000..cf59be2e7 --- /dev/null +++ b/projects/admin/src/app/api/organisation-api.service.ts @@ -0,0 +1,34 @@ +/* + * RERO ILS UI + * Copyright (C) 2021-2023 RERO + * Copyright (C) 2021-2023 UCLouvain + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import { Injectable } from '@angular/core'; +import { RecordService } from '@rero/ng-core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +@Injectable({ + providedIn: 'root' +}) +export class OrganisationApiService { + + constructor(private recordService: RecordService) { } + + getByPid(pid: string): Observable { + return this.recordService.getRecord('organisations', pid) + .pipe(map(record => record.metadata)); + } +} diff --git a/projects/admin/src/app/app.module.ts b/projects/admin/src/app/app.module.ts index a9fab8495..da5d44148 100644 --- a/projects/admin/src/app/app.module.ts +++ b/projects/admin/src/app/app.module.ts @@ -24,10 +24,12 @@ import { BrowserModule } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { PrimengImportModule } from '@app/admin/shared/primeng-import/primeng-import.module'; import { HotkeysModule, HotkeysService } from '@ngneat/hotkeys'; +import { FormlyFieldSelect } from '@ngx-formly/bootstrap'; import { FormlyModule } from '@ngx-formly/core'; import { LoadingBarHttpClientModule } from '@ngx-loading-bar/http-client'; import { TranslateLoader as BaseTranslateLoader, TranslateModule } from '@ngx-translate/core'; import { + BucketNameService as CoreBucketNameService, CoreConfigService, LocalStorageService, RecordModule, RemoteTypeaheadService, TranslateLoader, TranslateService, TruncateTextPipe } from '@rero/ng-core'; @@ -169,6 +171,8 @@ import { PermissionDetailViewComponent } from './record/detail-view/permission-d import { RecordMaskedComponent } from './record/detail-view/record-masked/record-masked.component'; import { TemplateDetailViewComponent } from './record/detail-view/template-detail-view/template-detail-view.component'; import { VendorDetailViewComponent } from './record/detail-view/vendor-detail-view/vendor-detail-view.component'; +import { FieldCustomInputTypeComponent } from './record/editor/type/field-custom.type'; +import { RepeatTypeComponent } from './record/editor/type/repeat-section.type'; import { IdentifiedbyValueComponent } from './record/editor/wrappers/identifiedby-value.component'; import { UserIdComponent } from './record/editor/wrappers/user-id/user-id.component'; import { CipoPatronTypeItemTypeComponent } from './record/formly/type/cipo-patron-type-item-type/cipo-patron-type-item-type.component'; @@ -177,6 +181,8 @@ import { AddEntityLocalComponent } from './record/formly/type/entity-typeahead/a import { EntityTypeaheadComponent } from './record/formly/type/entity-typeahead/entity-typeahead.component'; import { OperationLogsDialogComponent } from './record/operation-logs/operation-logs-dialog/operation-logs-dialog.component'; import { OperationLogsComponent } from './record/operation-logs/operation-logs.component'; +import { DocumentAdvancedSearchFormComponent } from './record/search-view/document-advanced-search-form/document-advanced-search-form.component'; +import { DocumentAdvancedSearchComponent } from './record/search-view/document-advanced-search.component'; import { DocumentRecordSearchComponent } from './record/search-view/document-record-search/document-record-search.component'; import { PatronTransactionEventSearchViewComponent } from './record/search-view/patron-transaction-event-search-view/patron-transaction-event-search-view.component'; import { PaymentsDataComponent } from './record/search-view/patron-transaction-event-search-view/payments-data/payments-data.component'; @@ -184,6 +190,7 @@ import { PaymentDataPieComponent } from './record/search-view/patron-transaction import { PaymentsDataTableComponent } from './record/search-view/patron-transaction-event-search-view/payments-data/table/payments-data-table.component'; import { AppConfigService } from './service/app-config.service'; import { AppInitializerService } from './service/app-initializer.service'; +import { BucketNameService } from './service/bucket-name.service'; import { OrganisationService } from './service/organisation.service'; import { TypeaheadFactoryService, typeaheadToken } from './service/typeahead-factory.service'; import { UiRemoteTypeaheadService } from './service/ui-remote-typeahead.service'; @@ -322,7 +329,11 @@ export function appInitFactory(appInitializerService: AppInitializerService): () LocalWorkDetailViewComponent, RemoteTopicDetailViewComponent, AddEntityLocalComponent, - AddEntityLocalFormComponent + AddEntityLocalFormComponent, + DocumentAdvancedSearchFormComponent, + RepeatTypeComponent, + FieldCustomInputTypeComponent, + DocumentAdvancedSearchComponent, ], imports: [ AppRoutingModule, @@ -343,7 +354,10 @@ export function appInitFactory(appInitializerService: AppInitializerService): () types: [ {name: 'cipo-pt-it', component: CipoPatronTypeItemTypeComponent}, {name: 'account-select', component: SelectAccountEditorWidgetComponent}, - {name: 'entityTypeahead', component: EntityTypeaheadComponent} + {name: 'entityTypeahead', component: EntityTypeaheadComponent}, + {name: 'repeat', component: RepeatTypeComponent}, + {name: 'select-formly', component: FormlyFieldSelect }, + {name: 'custom', component: FieldCustomInputTypeComponent } ], wrappers: [ {name: 'user-id', component: UserIdComponent}, @@ -428,7 +442,8 @@ export function appInitFactory(appInitializerService: AppInitializerService): () }, MainTitlePipe, ItemHoldingsCallNumberPipe, - CountryCodeTranslatePipe + CountryCodeTranslatePipe, + { provide: CoreBucketNameService, useClass: BucketNameService } ], bootstrap: [AppComponent], schemas: [ diff --git a/projects/admin/src/app/record/editor/type/field-custom.type.ts b/projects/admin/src/app/record/editor/type/field-custom.type.ts new file mode 100644 index 000000000..e67f3b067 --- /dev/null +++ b/projects/admin/src/app/record/editor/type/field-custom.type.ts @@ -0,0 +1,47 @@ +/* + * RERO ILS UI + * Copyright (C) 2019-2023 RERO + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import { Component, OnInit } from '@angular/core'; +import { FieldType } from '@ngx-formly/core'; + +@Component({ + selector: 'admin-field-custom-input', + template: ` +
+ + + +
+ + + + +
+
+ `, +}) +export class FieldCustomInputTypeComponent extends FieldType implements OnInit { + + /** OnInit hook */ + ngOnInit(): void { + // We delete the class, as it is already set to the top level by the config. + if (Object.keys(this.field).includes('className')) { + delete this.field.className; + } + } +} diff --git a/projects/admin/src/app/record/editor/type/repeat-section.type.ts b/projects/admin/src/app/record/editor/type/repeat-section.type.ts new file mode 100644 index 000000000..29d9577cd --- /dev/null +++ b/projects/admin/src/app/record/editor/type/repeat-section.type.ts @@ -0,0 +1,38 @@ +/* + * RERO ILS UI + * Copyright (C) 2019-2023 RERO + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import { Component } from '@angular/core'; +import { FieldArrayType } from '@ngx-formly/core'; + +@Component({ + selector: 'admin-repeat-section', + template: ` +
+
+ +
+
+ + +
+
+ `, +}) +export class RepeatTypeComponent extends FieldArrayType { } diff --git a/projects/admin/src/app/record/search-view/document-advanced-search-form/advanced-search.service.spec.ts b/projects/admin/src/app/record/search-view/document-advanced-search-form/advanced-search.service.spec.ts new file mode 100644 index 000000000..3d48cada5 --- /dev/null +++ b/projects/admin/src/app/record/search-view/document-advanced-search-form/advanced-search.service.spec.ts @@ -0,0 +1,228 @@ +/* + * RERO ILS UI + * Copyright (C) 2019-2023 RERO + * Copyright (C) 2021-2023 UCLouvain + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import { TestBed } from '@angular/core/testing'; +import { DocumentApiService } from '@app/admin/api/document-api.service'; +import { cloneDeep } from 'lodash-es'; +import { of } from 'rxjs'; +import { AdvancedSearchService } from './advanced-search.service'; + +describe('AdvancedSearchService', () => { + let service: AdvancedSearchService; + + const apiResponse = { + fieldsConfig: [ + { + field: "title.*", + label: "Title", + value: "title", + options: { + search_type: [ + { label: "contains", value: AdvancedSearchService.SEARCH_TYPE_CONTAINS }, + { label: "exact", value: AdvancedSearchService.SEARCH_TYPE_CONTAINS_PHRASE }, + ]}, + }, + { + field: "provisionActivity.place.country", + label: "Country", + value: "country", + options: { + search_type: [ + { label: "exact", value: AdvancedSearchService.SEARCH_TYPE_CONTAINS_PHRASE }, + ] + }, + } + ], + fieldsData: { + canton: [ + { label: "canton_ag", value: "ag" }, + { label: "canton_ai", value: "ai" } + ], + country: [ + { label: "country_aa", value: "aa" }, + { label: "country_abc", value: "abc" } + ], + rdaCarrierType: [], + rdaContentType: [], + rdaMediaType: [] + } + }; + + const documentApiServiceSpy = jasmine.createSpyObj('DocumentApiService', ['getAdvancedSearchConfig']); + documentApiServiceSpy.getAdvancedSearchConfig.and.returnValue(of(cloneDeep(apiResponse))); + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { provide: DocumentApiService, useValue: documentApiServiceSpy } + ] + }); + service = TestBed.inject(AdvancedSearchService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should return a boolean when the configuration is loaded', () => { + service.load().subscribe((loaded: boolean) => { + expect(loaded).toBeTruthy(); + }); + }); + + it('should return the fields configuration', () => { + const response = [ + { label: "Title", value: "title" }, + { label: "Country", value: "country" }, + ]; + service.load().subscribe(() => { + expect(service.getFieldsConfig()).toEqual(response); + }); + }); + + it('should return the fields data', () => { + service.load().subscribe(() => { + expect(service.getFieldsData()).toEqual(apiResponse.fieldsData); + }); + }); + + it('should return the fields search type data', () => { + const response = { + title: [ + { label: "contains", value: AdvancedSearchService.SEARCH_TYPE_CONTAINS }, + { label: "exact", value: AdvancedSearchService.SEARCH_TYPE_CONTAINS_PHRASE }, + ], + country: [ + { label: "exact", value: AdvancedSearchService.SEARCH_TYPE_CONTAINS_PHRASE }, + ] + }; + service.load().subscribe(() => { + expect(service.getFieldsSearchType()).toEqual(response); + }); + }); + + it('should return the operators', () => { + const response = [ + { label: 'and', value: AdvancedSearchService.OPERATOR_AND }, + { label: 'or', value: AdvancedSearchService.OPERATOR_OR }, + { label: 'and not', value: AdvancedSearchService.OPERATOR_NOT } + ]; + service.load().subscribe(() => { + expect(service.getOperators()).toEqual(response); + }); + }); + + it('should return the mapping', () => { + service.load().subscribe(() => { + expect(service.fieldMapping('title')).toEqual('title.*'); + }); + }); + + it('Should return an error if the field does not exist', () => { + service.load().subscribe(() => { + expect(function() { service.fieldMapping('foo') }) + .toThrowError(SyntaxError, 'Field mapping does not exist (foo)'); + }); + }); + + it('should return a search string', () => { + service.load().subscribe((loaded: boolean) => { + // CONTAINS + let searchString = 'title.\\*:(flamand)'; + let model = { + field: 'title', + term: 'flamand', + searchType: AdvancedSearchService.SEARCH_TYPE_CONTAINS, + search: [{ + operator: AdvancedSearchService.OPERATOR_AND, + field: 'title', + term: '', + searchType: AdvancedSearchService.SEARCH_TYPE_CONTAINS + }], + } + expect(service.generateQueryByModel(model)).toEqual(searchString); + + // CONTAINS: Test protect ( and ) + searchString = 'title.\\*:(\\(flamand\\))'; + model = { + field: 'title', + term: '(flamand)', + searchType: AdvancedSearchService.SEARCH_TYPE_CONTAINS, + search: [{ + operator: AdvancedSearchService.OPERATOR_AND, + field: 'title', + term: '', + searchType: AdvancedSearchService.SEARCH_TYPE_CONTAINS + }], + } + expect(service.generateQueryByModel(model)).toEqual(searchString); + + // PHRASE + searchString = 'title.\\*:"flamand"'; + model = { + field: 'title', + term: 'flamand', + searchType: AdvancedSearchService.SEARCH_TYPE_CONTAINS_PHRASE, + search: [{ + operator: AdvancedSearchService.OPERATOR_AND, + field: 'title', + term: '', + searchType: AdvancedSearchService.SEARCH_TYPE_CONTAINS + }], + } + expect(service.generateQueryByModel(model)).toEqual(searchString); + + // PHRASE: Test protect " + searchString = 'title.\\*:"\\"flamand\\""'; + model = { + field: 'title', + term: '"flamand"', + searchType: AdvancedSearchService.SEARCH_TYPE_CONTAINS_PHRASE, + search: [{ + operator: AdvancedSearchService.OPERATOR_AND, + field: 'title', + term: '', + searchType: AdvancedSearchService.SEARCH_TYPE_CONTAINS + }], + } + expect(service.generateQueryByModel(model)).toEqual(searchString); + + // MULTIPLE SEARCH + searchString = 'title.\\*:(flamand) AND title.\\*:"primitif" OR provisionActivity.place.country:"abc"'; + model = { + field: 'title', + term: 'flamand', + searchType: AdvancedSearchService.SEARCH_TYPE_CONTAINS, + search: [ + { + operator: AdvancedSearchService.OPERATOR_AND, + field: 'title', + term: 'primitif', + searchType: AdvancedSearchService.SEARCH_TYPE_CONTAINS_PHRASE + }, + { + operator: AdvancedSearchService.OPERATOR_OR, + field: 'country', + term: 'abc', + searchType: AdvancedSearchService.SEARCH_TYPE_CONTAINS_PHRASE + } + ], + } + expect(service.generateQueryByModel(model)).toEqual(searchString); + }) + }); +}); diff --git a/projects/admin/src/app/record/search-view/document-advanced-search-form/advanced-search.service.ts b/projects/admin/src/app/record/search-view/document-advanced-search-form/advanced-search.service.ts new file mode 100644 index 000000000..af4aa92f4 --- /dev/null +++ b/projects/admin/src/app/record/search-view/document-advanced-search-form/advanced-search.service.ts @@ -0,0 +1,183 @@ +/* + * RERO ILS UI + * Copyright (C) 2019-2023 RERO + * Copyright (C) 2021-2023 UCLouvain + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import { Injectable } from '@angular/core'; +import { DocumentApiService } from '@app/admin/api/document-api.service'; +import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; +import { Observable, of } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { IAdvancedSearchConfig, IFieldsData, IFieldsType, ILabelValue, ILabelValueField, ISearch, ISearchModel, ISelectOptions } from './i-advanced-search-config-interface'; + +@Injectable({ + providedIn: 'root' +}) +export class AdvancedSearchService { + + /** Field search types */ + public static SEARCH_TYPE_CONTAINS = 'contains'; + public static SEARCH_TYPE_CONTAINS_PHRASE = 'phrase'; + + /** Search operator */ + public static OPERATOR_AND = 'AND'; + public static OPERATOR_OR = 'OR'; + public static OPERATOR_NOT = 'AND NOT'; + + /** Fields options */ + public fieldOptions: ILabelValue[] = []; + + /** Fields data */ + public fieldData: IFieldsData; + + /** Fields mapping */ + private fieldMappingMap: Map; + + /** Fields searchType mapping */ + private fieldsSearchType: IFieldsType = {}; + + /** + * Constructor + * @param documentApiService - DocumentApiService + */ + constructor(private documentApiService: DocumentApiService) { } + + /** + * Load advanced search configuration + * @returns an observable with a Boolean that gives + * information about the loaded configuration. + */ + load(): Observable { + if (this.fieldData !== undefined) { + return of(undefined); + } + + return this.documentApiService.getAdvancedSearchConfig().pipe( + tap((config: IAdvancedSearchConfig) => this.process(config)), + ); + } + + /** + * Get operator + * @returns operators used for forms + */ + getOperators(): ISelectOptions[] { + return [ + { label: _('and'), value: AdvancedSearchService.OPERATOR_AND }, + { label: _('or'), value: AdvancedSearchService.OPERATOR_OR }, + { label: _('and not'), value: AdvancedSearchService.OPERATOR_NOT } + ] + } + + /** + * Field mapping + * @param field - the field name + * @returns the field config + */ + fieldMapping(field: string): string { + if (!this.fieldMappingMap.has(field)) { + throw new SyntaxError(`Field mapping does not exist (${field})`); + } + return this.fieldMappingMap.get(field); + } + + /** + * Get Fields config + * Implementation of a getter to prevent data modification + * @returns - fields options + */ + getFieldsConfig(): ILabelValue[] { + return this.fieldOptions; + } + + /** + * Get fields data + * Implementation of a getter to prevent data modification + * @returns - fields data options + */ + getFieldsData(): IFieldsData { + return this.fieldData; + } + + /** + * Get fields search type data + * Implementation of a getter to prevent data modification + * @returns - fields search type data options + */ + getFieldsSearchType(): any { + return this.fieldsSearchType; + } + + /** + * Generate query by model + * @param model - The form model + * @returns a query string + */ + generateQueryByModel(model: ISearchModel): string { + const query = []; + let field: string = this.protectStar(this.fieldMapping(model.field)); + let term: string = this.protectTerm(model.term, model.searchType); + query.push(`${field}:${term}`); + model.search.forEach((search: ISearch) => { + if (search.term) { + query.push(search.operator); + field = this.protectStar(this.fieldMapping(search.field)); + term = this.protectTerm(search.term, search.searchType); + query.push(`${field}:${term}`); + } + }); + return query.join(' '); + } + + /** + * Process + * @param config - the backend json config + */ + private process(config: IAdvancedSearchConfig): void { + this.fieldData = config.fieldsData; + const fieldMapping = []; + config.fieldsConfig.forEach((field: ILabelValueField) => { + if (field?.options?.search_type) { + this.fieldsSearchType[field.value] = field.options.search_type; + } + fieldMapping.push([field.value, field.field]); + this.fieldOptions.push({label: field.label, value: field.value}); + }); + this.fieldMappingMap = new Map(fieldMapping); + } + + /** + * Protect Star + * @param term - The string to project + * @returns a protected string + */ + private protectStar(term: string): string { + return term.replace(/\.\*/g, '.\\*'); + } + + /** + * Protect term + * @param term - The string to project + * @param searchType - The search type + * @returns a protected string and delimited + */ + private protectTerm(term: string, searchType: string): string { + if (searchType === AdvancedSearchService.SEARCH_TYPE_CONTAINS_PHRASE) { + return `"${term.replace(/\"/g, '\\"')}"`; + } + + return `(${term.replace(/\(/g, '\\(').replace(/\)/g, '\\)')})`; + } +} diff --git a/projects/admin/src/app/record/search-view/document-advanced-search-form/document-advanced-search-form.component.html b/projects/admin/src/app/record/search-view/document-advanced-search-form/document-advanced-search-form.component.html new file mode 100644 index 000000000..9a438fb4c --- /dev/null +++ b/projects/admin/src/app/record/search-view/document-advanced-search-form/document-advanced-search-form.component.html @@ -0,0 +1,33 @@ + + +
+
+ Build advanced query + +
+
+
+ + + Clear +
+
+
diff --git a/projects/admin/src/app/record/search-view/document-advanced-search-form/document-advanced-search-form.component.ts b/projects/admin/src/app/record/search-view/document-advanced-search-form/document-advanced-search-form.component.ts new file mode 100644 index 000000000..e536c4857 --- /dev/null +++ b/projects/admin/src/app/record/search-view/document-advanced-search-form/document-advanced-search-form.component.ts @@ -0,0 +1,324 @@ +/* + * RERO ILS UI + * Copyright (C) 2019-2023 RERO + * Copyright (C) 2021-2023 UCLouvain + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import { Component, EventEmitter, OnInit, Output } from '@angular/core'; +import { FormGroup } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; +import { FormlyFieldConfig, FormlyFormOptions } from '@ngx-formly/core'; +import { LocalStorageService } from '@rero/ng-core'; +import { BehaviorSubject } from 'rxjs'; +import { AdvancedSearchService } from './advanced-search.service'; +import { IFieldsData, IFieldsType, ISearchModel } from './i-advanced-search-config-interface'; + +@Component({ + selector: 'admin-document-advanced-search-form', + templateUrl: './document-advanced-search-form.component.html' +}) +export class DocumentAdvancedSearchFormComponent implements OnInit { + + /** Locale storage parameters */ + private static LOCALE_STORAGE_NAME = 'advancedSearch'; + private static LOCALE_STORAGE_EXPIRED_IN_SECONDS = 600; + + /** Closing event for the modal dialog */ + @Output() searchModel = new EventEmitter(false); + + /** Hide dialog event */ + @Output() hideDialog = new EventEmitter(); + + /** Configuration loaded from backend */ + configurationLoaded: boolean = false; + + /** Field data config with map */ + fieldDataConfig: IFieldsData; + + /** Field search type config */ + fieldsSearchTypeConfig: IFieldsType = {}; + + /** Form configuration */ + form = new FormGroup({}); + model: ISearchModel; + options: FormlyFormOptions = {}; + fieldsConfig: FormlyFieldConfig[] = []; + + fieldHook = function(field: any, selectName: string) { + const subject: BehaviorSubject = new BehaviorSubject([]); + const fieldControl = field.form.get(selectName); + this.initField(field, fieldControl.value, subject); + fieldControl.valueChanges.subscribe((fieldKey: string) => { + this.initField(field, fieldKey, subject); + field.formControl.setValue(null); + }); + } + + fieldSearchTypeHook = function(field: any, selectName: string) { + const subject: BehaviorSubject = new BehaviorSubject([]); + const fieldControl = field.form.get(selectName); + this.initFieldSearchType(field, fieldControl.value, subject); + fieldControl.valueChanges.subscribe((fieldKey: string) => { + this.initFieldSearchType(field, fieldKey, subject); + }); + } + + /** + * Constructor + * @param route - ActivatedRoute + * @param localeStorage - LocalStorageService + * @param AdvancedSearchService - AdvancedSearchService + */ + constructor( + private route: ActivatedRoute, + private localeStorage: LocalStorageService, + private advancedSearchService: AdvancedSearchService + ) {} + + /** OnInit hook */ + ngOnInit(): void { + const {q} = this.route.snapshot.queryParams; + this.initModel(); + this.advancedSearchService.load().subscribe(() => { + this.initializeFieldConfig(); + this.loadOrResetStorage(q); + this.configurationLoaded = true; + }); + } + + /** Event to notify dialog closure */ + close(): void { + this.hideDialog.emit(true); + } + + /** Clear the form and return it to its original state */ + clear(): void { + this.initModel(); + this.localeStorage.remove(DocumentAdvancedSearchFormComponent.LOCALE_STORAGE_NAME); + } + + /** Submit */ + submit(): void { + this.localeStorage.set(DocumentAdvancedSearchFormComponent.LOCALE_STORAGE_NAME, this.model); + this.searchModel.emit(this.generateQueryByModel(this.model)); + } + + /** Init formly model */ + private initModel(): void { + this.model = { + field: 'title', + term: '', + searchType: AdvancedSearchService.SEARCH_TYPE_CONTAINS, + search: [{ + operator: AdvancedSearchService.OPERATOR_AND, + field: 'title', + term: '', + searchType: AdvancedSearchService.SEARCH_TYPE_CONTAINS + }], + } + } + + /** + * Local storage management + * @param q - Query string + */ + private loadOrResetStorage(q: string): void { + if (this.localeStorage.has(DocumentAdvancedSearchFormComponent.LOCALE_STORAGE_NAME)) { + if (this.localeStorage.isExpired( + DocumentAdvancedSearchFormComponent.LOCALE_STORAGE_NAME, + DocumentAdvancedSearchFormComponent.LOCALE_STORAGE_EXPIRED_IN_SECONDS + )) { + this.localeStorage.remove(DocumentAdvancedSearchFormComponent.LOCALE_STORAGE_NAME); + } else { + const queryModel = this.localeStorage.get(DocumentAdvancedSearchFormComponent.LOCALE_STORAGE_NAME); + if (q === this.generateQueryByModel(queryModel)) { + this.model = queryModel; + } else { + this.localeStorage.remove(DocumentAdvancedSearchFormComponent.LOCALE_STORAGE_NAME); + } + } + } + } + + /** + * Init field + * @param field The current field + * @param fieldParentKey The key of parent field + * @param subject - Behavior Event + */ + private initField(field: any, fieldParentKey: string, subject: BehaviorSubject): void { + if (Object.keys(this.fieldDataConfig).some(key => key === fieldParentKey)) { + field.templateOptions = { + options: subject.asObservable(), + minItemsToDisplaySearch: 10, + sort: false, + }; + field.type = 'select'; + subject.next(this.fieldDataConfig[fieldParentKey]); + } else { + field.type = 'input'; + } + } + + /** + * Init field search type + * @param field The current field + * @param fieldParentKey The key of parent field + * @param subject - Behavior Event + */ + private initFieldSearchType(field: any, fieldParentKey: string, subject: BehaviorSubject): void { + if (Object.keys(this.fieldsSearchTypeConfig).some(key => key === fieldParentKey)) { + field.templateOptions = { + options: subject.asObservable(), + sort: false, + }; + subject.next(this.fieldsSearchTypeConfig[fieldParentKey]); + const fieldSearchTypeValue = this.fieldsSearchTypeConfig[fieldParentKey][0].value; + if ( + !field.formControl.value + || (field.formControl.value && fieldSearchTypeValue !== field.formControl.value) + ) { + field.formControl.setValue(fieldSearchTypeValue); + } + } + } + + /** + * Generate query by model + * @param model - The form model + * @returns a query string + */ + private generateQueryByModel(model: ISearchModel): string { + return this.advancedSearchService.generateQueryByModel(model); + } + + /** Initializing the Formly configuration */ + private initializeFieldConfig(): void { + this.fieldDataConfig = this.advancedSearchService.getFieldsData(); + this.fieldsSearchTypeConfig = this.advancedSearchService.getFieldsSearchType(); + this.fieldsConfig = [ + { + fieldGroupClassName: 'row', + fieldGroup: [ + { + fieldGroupClassName: 'row', + className: 'col-11', + fieldGroup: [ + { + className: 'col-6', + type: 'select', + key: 'field', + defaultValue: 'title', + templateOptions: { + sort: false, + hideLabel: true, + required: true, + options: this.advancedSearchService.getFieldsConfig(), + }, + }, + { + className: 'col-2', + type: 'select', + key: 'searchType', + defaultValue: false, + templateOptions: { + options: [] + }, + hooks: { + onInit: (field) => this.fieldSearchTypeHook(field, 'field') + } + }, + { + className: 'col-4', + type: 'custom', + key: 'term', + templateOptions: { + hideLabel: true, + required: true, + }, + hooks: { + onInit: (field) => this.fieldHook(field, 'field') + } + }, + ] + }, + ], + }, + { + key: 'search', + type: 'repeat', + templateOptions: { + maxItems: 4, + minItems: 1, + }, + fieldArray: { + fieldGroup: [ + { + fieldGroupClassName: 'row', + fieldGroup: [ + { + className: 'col-2', + type: 'select', + key: 'operator', + defaultValue: AdvancedSearchService.OPERATOR_AND, + templateOptions: { + hideLabel: true, + required: true, + options: this.advancedSearchService.getOperators(), + }, + }, + { + className: 'col-4', + type: 'select', + key: 'field', + defaultValue: 'title', + templateOptions: { + sort: false, + hideLabel: true, + required: true, + options: this.advancedSearchService.getFieldsConfig(), + }, + }, + { + className: 'col-2', + type: 'select', + key: 'searchType', + defaultValue: false, + templateOptions: { + options: [] + }, + hooks: { + onInit: (field) => this.fieldSearchTypeHook(field, 'field') + } + }, + { + className: 'col-4', + type: 'custom', + key: 'term', + templateOptions: { + hideLabel: true, + options: [], + }, + hooks: { + onInit: (field) => this.fieldHook(field, 'field') + } + }, + ], + }, + ], + }, + }, + ] + } +} diff --git a/projects/admin/src/app/record/search-view/document-advanced-search-form/i-advanced-search-config-interface.ts b/projects/admin/src/app/record/search-view/document-advanced-search-form/i-advanced-search-config-interface.ts new file mode 100644 index 000000000..9c4f7c7f3 --- /dev/null +++ b/projects/admin/src/app/record/search-view/document-advanced-search-form/i-advanced-search-config-interface.ts @@ -0,0 +1,65 @@ +/* + * RERO ILS UI + * Copyright (C) 2019-2023 RERO + * Copyright (C) 2021-2023 UCLouvain + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +export interface IAdvancedSearchConfig { + fieldsConfig: ILabelValueField[]; + fieldsData: IFieldsData +} + +export interface IFieldsData { + canton: ILabelValue[], + country: ILabelValue[], + rdaCarrierType: ILabelValue[], + rdaContentType: ILabelValue[], + rdaMediaType: ILabelValue[], +} + +export interface ILabelValueField extends ILabelValue { + field: string + options: { + search_type: ILabelValue[]; + } +} + +export interface ILabelValue { + label: string; + value: string; +} + +export interface ISearch { + field: string; + term?: string; + operator: string; + searchType: string; +} + +export interface ISearchModel { + field: string; + term: string; + searchType: string; + search: ISearch[] +} + +export interface ISelectOptions { + label: string; + value: string; + preferred?: boolean; +} + +export interface IFieldsType { + [key: string]: ILabelValue[]; +} diff --git a/projects/admin/src/app/record/search-view/document-advanced-search.component.ts b/projects/admin/src/app/record/search-view/document-advanced-search.component.ts new file mode 100644 index 000000000..4df56f158 --- /dev/null +++ b/projects/admin/src/app/record/search-view/document-advanced-search.component.ts @@ -0,0 +1,87 @@ +/* + * RERO ILS UI + * Copyright (C) 2019-2023 RERO + * Copyright (C) 2021-2023 UCLouvain + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import { Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { BsModalService } from 'ngx-bootstrap/modal'; +import { Subscription } from 'rxjs'; +import { DocumentAdvancedSearchFormComponent } from './document-advanced-search-form/document-advanced-search-form.component'; + +@Component({ + selector: 'admin-document-advanced-search', + template: ` + + ` +}) +export class DocumentAdvancedSearchComponent implements OnInit, OnDestroy { + + /** Simple search */ + simple: boolean = true; + + /** Query event */ + @Output() queryString = new EventEmitter(); + + /** all component subscription */ + private _subscriptions = new Subscription(); + + /** + * Constructor + * @param route - ActivatedRoute + * @param modalService - BsModalService + */ + constructor( + private route: ActivatedRoute, + private modalService: BsModalService, + ) { } + + /** OnInit hook */ + ngOnInit(): void { + this._subscriptions.add(this.route.queryParams.subscribe((params: any) => { + if (params.simple) { + if (Array.isArray(params.simple)) { + this.simple = params.simple.length > 0 ? ('1' === params.simple.pop()) : true; + } else { + this.simple = ('1' === params.simple); + } + } else { + this.simple = true; + } + })); + } + + /** OnDestroy hook */ + ngOnDestroy(): void { + this._subscriptions.unsubscribe(); + } + + /** Opening the advanced search dialog */ + openModalBox(): void { + const modalRef = this.modalService.show(DocumentAdvancedSearchFormComponent, { + ignoreBackdropClick: true, + keyboard: true, + class: 'modal-xl', + }); + modalRef.content.hideDialog.subscribe(() => modalRef.hide()); + modalRef.content.searchModel.subscribe((queryString: string) => { + this.queryString.emit(queryString); + modalRef.hide(); + }); + } +} diff --git a/projects/admin/src/app/record/search-view/document-record-search/document-record-search.component.html b/projects/admin/src/app/record/search-view/document-record-search/document-record-search.component.html index 527d9d5d3..c447a53fa 100644 --- a/projects/admin/src/app/record/search-view/document-record-search/document-record-search.component.html +++ b/projects/admin/src/app/record/search-view/document-record-search/document-record-search.component.html @@ -1,6 +1,7 @@