From ae6db0293b5080ca4bd928b713a7e1a5fd8c4fc1 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 --- package-lock.json | 5 +- package.json | 2 +- .../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 | 44 +++ .../advanced-search-config.service.spec.ts | 122 +++++++++ .../advanced-search-config.service.ts | 115 ++++++++ ...cument-advanced-search-form.component.html | 33 +++ ...document-advanced-search-form.component.ts | 255 ++++++++++++++++++ .../i-advanced-search-config-interface.ts | 56 ++++ .../document-advanced-search.component.ts | 83 ++++++ .../document-record-search.component.html | 7 +- .../document-record-search.component.spec.ts | 140 ---------- .../document-record-search.component.ts | 31 ++- .../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 + 21 files changed, 1045 insertions(+), 151 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-config.service.spec.ts create mode 100644 projects/admin/src/app/record/search-view/document-advanced-search-form/advanced-search-config.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/package-lock.json b/package-lock.json index f62dd82ce..0affc51ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3127,9 +3127,8 @@ } }, "@rero/ng-core": { - "version": "14.6.0", - "resolved": "https://registry.npmjs.org/@rero/ng-core/-/ng-core-14.6.0.tgz", - "integrity": "sha512-opokeiWkLLXs5SCmouzatgwqLs61ZBWdruGzKrw8GuHfMFvKDyip56XYz673qGuXd49pt0HERZpfmZqHpEW/BQ==", + "version": "file:../ng-core/rero-ng-core-14.6.0.tgz", + "integrity": "sha512-ewrQzIy11Mq03PLOF7Y/q7SvzvOVsgnRj/zF8vltluI6+Le/iYKn2+dRSp71nXcZdzU7CGRn/doZT4nd+oku6g==", "requires": { "tslib": "^2.3.0" } diff --git a/package.json b/package.json index fa8a360e7..d213bb5dd 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ "@ngx-loading-bar/http-client": "^6.0.0", "@ngx-loading-bar/router": "^6.0.0", "@ngx-translate/core": "^14.0.0", - "@rero/ng-core": "^14.6.0", + "@rero/ng-core": "file:../ng-core/rero-ng-core-14.6.0.tgz", "@swimlane/ngx-charts": "^20.1.2", "bootstrap": "^4.6.2", "crypto-js": "^4.1.1", 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..4d157416a --- /dev/null +++ b/projects/admin/src/app/record/editor/type/repeat-section.type.ts @@ -0,0 +1,44 @@ +/* + * 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-config.service.spec.ts b/projects/admin/src/app/record/search-view/document-advanced-search-form/advanced-search-config.service.spec.ts new file mode 100644 index 000000000..825833436 --- /dev/null +++ b/projects/admin/src/app/record/search-view/document-advanced-search-form/advanced-search-config.service.spec.ts @@ -0,0 +1,122 @@ +/* + * 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 { AdvancedSearchConfigService } from './advanced-search-config.service'; + +describe('AdvancedSearchConfigService', () => { + let service: AdvancedSearchConfigService; + + const apiResponse = { + fieldsConfig: [ + { "field": "title.*", "label": "Title", "value": "title" }, + { "field": "provisionActivity.country", "label": "Country", "value": "country" } + ], + 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(AdvancedSearchConfigService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should return a boolean when the configuration is loaded', () => { + service.load().subscribe((loaded: boolean) => { + console.log(service.getFieldsConfig()); + 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', () => { + const response = { + 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: [] + }; + service.load().subscribe(() => { + expect(service.getFieldsData()).toEqual(response); + }); + }); + + it('should return the operators', () => { + const response = [ + { label: 'and', value: 'AND' }, + { label: 'or', value: 'OR' }, + { label: 'and not', value: 'AND 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)'); + }); + }); +}); diff --git a/projects/admin/src/app/record/search-view/document-advanced-search-form/advanced-search-config.service.ts b/projects/admin/src/app/record/search-view/document-advanced-search-form/advanced-search-config.service.ts new file mode 100644 index 000000000..bcd139fe4 --- /dev/null +++ b/projects/admin/src/app/record/search-view/document-advanced-search-form/advanced-search-config.service.ts @@ -0,0 +1,115 @@ +/* + * 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, ILabelValue, ILabelValueField, ISelectOptions } from './i-advanced-search-config-interface'; + +@Injectable({ + providedIn: 'root' +}) +export class AdvancedSearchConfigService { + + /** Fields options */ + public fieldOptions: ILabelValue[] = []; + + /** Fields data */ + public fieldData: IFieldsData; + + /** Fields mapping */ + private fieldMappingMap: Map; + + /** + * 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: 'AND' }, + { label: _('or'), value: 'OR' }, + { label: _('and not'), value: 'AND 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; + } + + /** + * Process + * @param config - the backend json config + */ + private process(config: IAdvancedSearchConfig): void { + this.fieldData = config.fieldsData; + const fieldMapping = []; + config.fieldsConfig.forEach((field: ILabelValueField) => { + fieldMapping.push([field.value, field.field]); + this.fieldOptions.push({label: field.label, value: field.value}); + }); + this.fieldMappingMap = new Map(fieldMapping); + } +} 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..14174ae9f --- /dev/null +++ b/projects/admin/src/app/record/search-view/document-advanced-search-form/document-advanced-search-form.component.ts @@ -0,0 +1,255 @@ +/* + * 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 { FormlyFieldConfig, FormlyFormOptions } from '@ngx-formly/core'; +import { BehaviorSubject } from 'rxjs'; +import { ActivatedRoute } from '@angular/router'; +import { LocalStorageService } from '@rero/ng-core'; +import { AdvancedSearchConfigService } from './advanced-search-config.service'; +import { IFieldsData, ISearch, 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 = 30; + + /** 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; + + /** Form configuration */ + form = new FormGroup({}); + model: ISearchModel = { search: [{}], }; + 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((value: string) => { + this.initField(field, value, subject); + field.formControl.setValue(null); + }); + } + + /** + * Constructor + * @param route - ActivatedRoute + * @param localeStorage - LocalStorageService + */ + constructor( + private route: ActivatedRoute, + private localeStorage: LocalStorageService, + private advancedSearchConfigService: AdvancedSearchConfigService + ) {} + + /** OnInit hook */ + ngOnInit(): void { + const {q} = this.route.snapshot.queryParams; + this.loadOrResetStorage(q); + this.advancedSearchConfigService.load().subscribe(() => { + this.initializeFieldConfig(); + 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.model = { + field: 'title', + term: '', + search: [{ + 'operator': 'AND', + 'field': 'title', + 'term': '' + }], + } + 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)); + } + + /** + * 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 - field + * @param fieldParentValue - value of field + * @param subject - Behavior + */ + private initField(field: any, fieldParentValue: string, subject: BehaviorSubject) { + if (Object.keys(this.fieldDataConfig).some(key => key === fieldParentValue)) { + field.templateOptions = { + options: subject.asObservable(), + minItemsToDisplaySearch: 10, + sort: true, + }; + field.type = 'select'; + subject.next(this.fieldDataConfig[fieldParentValue]); + } else { + field.type = 'input'; + } + } + + /** + * Generate query by model + * @param model - The form model + * @returns a query string + */ + private generateQueryByModel(model: ISearchModel): string { + const query = []; + query.push(`${this.advancedSearchConfigService.fieldMapping(model.field).replace('.*', '.\\*')}:"${model.term}"`); + model.search.forEach((search: ISearch) => { + if (search.term) { + query.push(search.operator); + query.push(`${this.advancedSearchConfigService.fieldMapping(search.field).replace('.*', '.\\*')}:"${search.term}"`); + } + }); + return query.join(' '); + } + + /** Initializing the Formly configuration */ + private initializeFieldConfig(): void { + this.fieldDataConfig = this.advancedSearchConfigService.getFieldsData(); + this.fieldsConfig = [ + { + fieldGroupClassName: 'row', + fieldGroup: [ + { + className: 'col-6', + type: 'select', + key: 'field', + defaultValue: 'title', + templateOptions: { + hideLabel: true, + required: true, + options: this.advancedSearchConfigService.getFieldsConfig() + }, + }, + { + className: 'col-6', + 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: 'AND', + templateOptions: { + hideLabel: true, + required: true, + options: this.advancedSearchConfigService.getOperators(), + }, + }, + { + className: 'col-5', + type: 'select', + key: 'field', + defaultValue: 'title', + templateOptions: { + hideLabel: true, + required: true, + options: this.advancedSearchConfigService.getFieldsConfig(), + }, + }, + { + className: 'col-5', + 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..b0c759b4a --- /dev/null +++ b/projects/admin/src/app/record/search-view/document-advanced-search-form/i-advanced-search-config-interface.ts @@ -0,0 +1,56 @@ +/* + * 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 +} + +export interface ILabelValue { + label: string; + value: string; +} + +export interface ISearch { + field?: string; + term?: string; + operator?: string; +} + +export interface ISearchModel { + field?: string; + term?: string; + search: ISearch[] +} + +export interface ISelectOptions { + label: string; + value: string; + preferred?: boolean; +} 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..90e5ef15b --- /dev/null +++ b/projects/admin/src/app/record/search-view/document-advanced-search.component.ts @@ -0,0 +1,83 @@ +/* + * 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) => { + this.simple = ['1', true].includes(Array.isArray(params.simple) ? params.simple.pop() : params.simple); + })); + } + + /** 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..5391b21ff 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 @@