From 48550cad105008f19b878e714158063b5f1703c0 Mon Sep 17 00:00:00 2001 From: Kim Shepherd Date: Thu, 5 Jun 2025 18:28:50 +0200 Subject: [PATCH] [TLC-790] Solr-based suggestion in onebox --- .../browse-by-taxonomy.component.ts | 2 +- .../shared/form/models/form-field.model.ts | 5 + .../models/vocabulary-options.model.ts | 8 +- ...metadata-authority-field.component.spec.ts | 5 + .../onebox/dynamic-onebox.component.html | 171 +++++++-------- .../onebox/dynamic-onebox.component.spec.ts | 15 +- .../models/onebox/dynamic-onebox.component.ts | 199 +++++++++--------- .../dynamic-relation-group.component.spec.ts | 5 + .../form/builder/parsers/field-parser.ts | 1 + src/app/shared/form/form.reducer.ts | 1 + src/app/shared/search/search.service.ts | 48 +++++ .../shared/search/suggestion-entry.model.ts | 54 +++++ src/assets/i18n/bn.json5 | 2 + src/assets/i18n/ca.json5 | 3 + src/assets/i18n/cs.json5 | 3 + src/assets/i18n/de.json5 | 3 + src/assets/i18n/en.json5 | 2 + 17 files changed, 333 insertions(+), 194 deletions(-) create mode 100644 src/app/shared/search/suggestion-entry.model.ts diff --git a/src/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.ts b/src/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.ts index 05bd4e07f14..5d08ebbec1e 100644 --- a/src/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.ts +++ b/src/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.ts @@ -128,7 +128,7 @@ export class BrowseByTaxonomyComponent implements OnInit, OnChanges, OnDestroy { this.selectedItems = []; this.facetType = browseDefinition.facetType; this.vocabularyName = browseDefinition.vocabulary; - this.vocabularyOptions = { name: this.vocabularyName, closed: true }; + this.vocabularyOptions = { name: this.vocabularyName, closed: true, type: 'xml' }; this.description = this.translate.instant(`browse.metadata.${this.vocabularyName}.tree.description`); })); this.subs.push(this.scope$.subscribe(() => { diff --git a/src/app/core/shared/form/models/form-field.model.ts b/src/app/core/shared/form/models/form-field.model.ts index 5359ff5a99b..3785038146e 100644 --- a/src/app/core/shared/form/models/form-field.model.ts +++ b/src/app/core/shared/form/models/form-field.model.ts @@ -28,6 +28,11 @@ export interface SelectableMetadata { * A boolean representing if value is closely related to the controlled vocabulary entry or not */ closed: boolean; + + /** + * The type (source) of the vocabulary: xml, authority, suggest + */ + vocabularyType: string; } /** diff --git a/src/app/core/submission/vocabularies/models/vocabulary-options.model.ts b/src/app/core/submission/vocabularies/models/vocabulary-options.model.ts index 7f54b8599d6..8b1604be038 100644 --- a/src/app/core/submission/vocabularies/models/vocabulary-options.model.ts +++ b/src/app/core/submission/vocabularies/models/vocabulary-options.model.ts @@ -13,9 +13,15 @@ export class VocabularyOptions { */ closed: boolean; + /** + * The type of the vocabulary (source): xml, authority, suggest + */ + type?: string; + constructor(name: string, - closed: boolean = false) { + closed: boolean = false, type: string = 'xml') { this.name = name; this.closed = closed; + this.type = type; } } diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.spec.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.spec.ts index 9eadba92488..610eb92ba23 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.spec.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.spec.ts @@ -15,10 +15,12 @@ import { Item } from '@dspace/core/shared/item.model'; import { MetadataValue } from '@dspace/core/shared/metadata.models'; import { Vocabulary } from '@dspace/core/submission/vocabularies/models/vocabulary.model'; import { VocabularyService } from '@dspace/core/submission/vocabularies/vocabulary.service'; +import { SearchServiceStub } from '@dspace/core/testing/search-service.stub'; import { createPaginatedList } from '@dspace/core/testing/utils.test'; import { VocabularyServiceStub } from '@dspace/core/testing/vocabulary-service.stub'; import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; import { TranslateModule } from '@ngx-translate/core'; +import { SearchService } from 'src/app/shared/search/search.service'; import { RegistryService } from '../../../../admin/admin-registries/registry/registry.service'; import { DynamicOneboxModel } from '../../../../shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.model'; @@ -32,6 +34,7 @@ describe('DsoEditMetadataAuthorityFieldComponent', () => { let fixture: ComponentFixture; let vocabularyService: any; + let searchService: any; let itemService: ItemDataService; let registryService: RegistryService; let notificationsService: NotificationsService; @@ -112,6 +115,7 @@ describe('DsoEditMetadataAuthorityFieldComponent', () => { findByHref: createSuccessfulRemoteDataObject$(item), }); vocabularyService = new VocabularyServiceStub(); + searchService = new SearchServiceStub(); registryService = jasmine.createSpyObj('registryService', { queryMetadataFields: createSuccessfulRemoteDataObject$(createPaginatedList(metadataFields)), }); @@ -150,6 +154,7 @@ describe('DsoEditMetadataAuthorityFieldComponent', () => { ], providers: [ { provide: VocabularyService, useValue: vocabularyService }, + { provide: SearchService, useValue: searchService }, { provide: ItemDataService, useValue: itemService }, { provide: RegistryService, useValue: registryService }, { provide: NotificationsService, useValue: notificationsService }, diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.html index da6de76594c..5822e5faf68 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.html @@ -1,91 +1,94 @@ - - - - - - +
    -
  • {{entry.value}}
  • - @for (item of entry.otherInformation | dsObjNgFor; track item) { -
  • - {{ 'form.other-information.' + item.key | translate }} : {{item.value}} -
  • + @if (listEntry.display) { +
  • +
  • + } @else { +
  • + {{listEntry.value}} +
  • } -
-
- -
    -
  • {{entry.value}}
  • + @if (listEntry.hasOtherInformation()) { + @for (item of listEntry.otherInformation | dsObjNgFor; track item.key) { + +
  • + {{ 'form.other-information.' + item.key | translate }} : {{item.value}} +
  • + + }}
-@if ((isHierarchicalVocabulary() | async) !== true) { -
-
- @if (searching || loadingInitialValue) { - - } - @if (!searching && !loadingInitialValue) { - - } -
- - @if (searchFailed) { -
Sorry, suggestions could not be loaded.
- } -
-} +
+ + @if ((vocabulary$ | async)?.hierarchical) { + + + + + } @else { + + @if (loading) { + + } @else { + + } + + + + @if (searchFailed) { +
Sorry, suggestions could not be loaded.
+ } -@if ((isHierarchicalVocabulary() | async)) { -
- - -
-} + } +
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts index 8a62addfb01..c56721a3a02 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts @@ -28,6 +28,7 @@ import { mockDynamicFormLayoutService, mockDynamicFormValidationService, } from '@dspace/core/testing/dynamic-form-mock-services'; +import { SearchServiceStub } from '@dspace/core/testing/search-service.stub'; import { createTestComponent } from '@dspace/core/testing/utils.test'; import { VocabularyServiceStub } from '@dspace/core/testing/vocabulary-service.stub'; import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; @@ -44,6 +45,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { getTestScheduler } from 'jasmine-marbles'; import { of } from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; +import { SearchService } from 'src/app/shared/search/search.service'; import { ObjNgFor } from '../../../../../utils/object-ngfor.pipe'; import { AuthorityConfidenceStateDirective } from '../../../../directives/authority-confidence-state.directive'; @@ -97,6 +99,7 @@ describe('DsDynamicOneboxComponent test suite', () => { let testFixture: ComponentFixture; let oneboxCompFixture: ComponentFixture; let vocabularyServiceStub: any; + let searchServiceStub: any; let modalService: any; let html; let modal; @@ -137,6 +140,7 @@ describe('DsDynamicOneboxComponent test suite', () => { // waitForAsync beforeEach beforeEach(() => { vocabularyServiceStub = new VocabularyServiceStub(); + searchServiceStub = new SearchServiceStub(); modal = jasmine.createSpyObj('modal', { @@ -164,6 +168,7 @@ describe('DsDynamicOneboxComponent test suite', () => { ChangeDetectorRef, DsDynamicOneboxComponent, { provide: VocabularyService, useValue: vocabularyServiceStub }, + { provide: SearchService, useValue: searchServiceStub }, { provide: DynamicFormLayoutService, useValue: mockDynamicFormLayoutService }, { provide: DynamicFormValidationService, useValue: mockDynamicFormValidationService }, { provide: NgbModal, useValue: modal }, @@ -259,14 +264,14 @@ describe('DsDynamicOneboxComponent test suite', () => { it('should emit blur Event onBlur when popup is closed', () => { spyOn(oneboxComponent.blur, 'emit'); - spyOn(oneboxComponent.instance, 'isPopupOpen').and.returnValue(false); + spyOn(oneboxComponent.typeahead, 'isPopupOpen').and.returnValue(false); oneboxComponent.onBlur(new Event('blur')); expect(oneboxComponent.blur.emit).toHaveBeenCalled(); }); it('should not emit blur Event onBlur when popup is opened', () => { spyOn(oneboxComponent.blur, 'emit'); - spyOn(oneboxComponent.instance, 'isPopupOpen').and.returnValue(true); + spyOn(oneboxComponent.typeahead, 'isPopupOpen').and.returnValue(true); const input = oneboxCompFixture.debugElement.query(By.css('input')); input.nativeElement.blur(); @@ -278,7 +283,7 @@ describe('DsDynamicOneboxComponent test suite', () => { oneboxCompFixture.detectChanges(); spyOn(oneboxComponent.blur, 'emit'); spyOn(oneboxComponent.change, 'emit'); - spyOn(oneboxComponent.instance, 'isPopupOpen').and.returnValue(false); + spyOn(oneboxComponent.typeahead, 'isPopupOpen').and.returnValue(false); oneboxComponent.onBlur(new Event('blur')); expect(oneboxComponent.change.emit).toHaveBeenCalled(); expect(oneboxComponent.blur.emit).toHaveBeenCalled(); @@ -291,7 +296,7 @@ describe('DsDynamicOneboxComponent test suite', () => { oneboxCompFixture.detectChanges(); spyOn(oneboxComponent.blur, 'emit'); spyOn(oneboxComponent.change, 'emit'); - spyOn(oneboxComponent.instance, 'isPopupOpen').and.returnValue(false); + spyOn(oneboxComponent.typeahead, 'isPopupOpen').and.returnValue(false); oneboxComponent.onBlur(new Event('blur')); expect(oneboxComponent.change.emit).not.toHaveBeenCalled(); expect(oneboxComponent.blur.emit).toHaveBeenCalled(); @@ -304,7 +309,7 @@ describe('DsDynamicOneboxComponent test suite', () => { oneboxCompFixture.detectChanges(); spyOn(oneboxComponent.blur, 'emit'); spyOn(oneboxComponent.change, 'emit'); - spyOn(oneboxComponent.instance, 'isPopupOpen').and.returnValue(false); + spyOn(oneboxComponent.typeahead, 'isPopupOpen').and.returnValue(false); oneboxComponent.onBlur(new Event('blur')); expect(oneboxComponent.change.emit).not.toHaveBeenCalled(); expect(oneboxComponent.blur.emit).toHaveBeenCalled(); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts index 8b3901106e7..4ab9c783ca4 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts @@ -1,7 +1,4 @@ -import { - AsyncPipe, - NgTemplateOutlet, -} from '@angular/common'; +import { AsyncPipe } from '@angular/common'; import { ChangeDetectorRef, Component, @@ -16,16 +13,11 @@ import { FormsModule, UntypedFormGroup, } from '@angular/forms'; -import { - buildPaginatedList, - PaginatedList, -} from '@dspace/core/data/paginated-list.model'; import { ConfidenceType } from '@dspace/core/shared/confidence-type'; import { FormFieldMetadataValueObject } from '@dspace/core/shared/form/models/form-field-metadata-value.model'; import { getFirstSucceededRemoteDataPayload } from '@dspace/core/shared/operators'; import { PageInfo } from '@dspace/core/shared/page-info.model'; import { Vocabulary } from '@dspace/core/submission/vocabularies/models/vocabulary.model'; -import { VocabularyEntry } from '@dspace/core/submission/vocabularies/models/vocabulary-entry.model'; import { VocabularyEntryDetail } from '@dspace/core/submission/vocabularies/models/vocabulary-entry-detail.model'; import { VocabularyService } from '@dspace/core/submission/vocabularies/vocabulary.service'; import { @@ -54,15 +46,16 @@ import { } from 'rxjs'; import { catchError, + concatMap, debounceTime, distinctUntilChanged, filter, map, - merge, - switchMap, + mergeWith, take, tap, } from 'rxjs/operators'; +import { SearchService } from 'src/app/shared/search/search.service'; import { ObjNgFor } from '../../../../../utils/object-ngfor.pipe'; import { AuthorityConfidenceStateDirective } from '../../../../directives/authority-confidence-state.directive'; @@ -82,8 +75,9 @@ import { DynamicOneboxModel } from './dynamic-onebox.model'; AsyncPipe, AuthorityConfidenceStateDirective, FormsModule, + FormsModule, + NgbTypeaheadModule, NgbTypeaheadModule, - NgTemplateOutlet, ObjNgFor, TranslateModule, ], @@ -97,129 +91,113 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple @Output() change: EventEmitter = new EventEmitter(); @Output() focus: EventEmitter = new EventEmitter(); - @ViewChild('instance') instance: NgbTypeahead; + @ViewChild('typeahead') typeahead: NgbTypeahead; - pageInfo: PageInfo = new PageInfo(); - searching = false; - loadingInitialValue = false; + pageInfo = new PageInfo(); + loading = false; searchFailed = false; - hideSearchingWhenUnsubscribed$ = new Observable(() => () => this.changeSearchingStatus(false)); click$ = new Subject(); currentValue: any; inputValue: any; - preloadLevel: number; - private vocabulary$: Observable; - private isHierarchicalVocabulary$: Observable; + get isSolrSuggest() { + return this.model.vocabularyOptions?.type === 'suggest'; + } + + get vocabularyName() { + return this.model.vocabularyOptions?.name; + } + + get vocabulary$() { + if (this.isSolrSuggest) { + return of(); + } + + return this.vocabularyService.findVocabularyById( + this.model.vocabularyOptions.name).pipe( + getFirstSucceededRemoteDataPayload(), + distinctUntilChanged()); + } + private subs: Subscription[] = []; + /** + * Converts a stream of text values from the `` element to a stream + * of search terms to send to the Solr suggest request handler for lookup + * of values in metadata or a flat file dictionary + */ + search = (text$: Observable) => text$.pipe( + mergeWith(this.click$), + debounceTime(300), + distinctUntilChanged(), + concatMap((term: string) => { + this.changeLoadingStatus(true); + this.searchFailed = false; + + if (term === '' || term.length < this.model.minChars) { + return of([]); + } + + if (this.isSolrSuggest) { + return this.searchService.getSuggestionsFor( + term, this.vocabularyName); + } + + return this.vocabularyService.getVocabularyEntriesByValue( + term, + false, + this.model.vocabularyOptions, + new PageInfo()).pipe(getFirstSucceededRemoteDataPayload()); + }), + tap(() => this.searchFailed = false), + catchError((err: unknown) => { + console.error('Onebox search failed', err); + this.searchFailed = true; + return of([]); + }), + tap(() => this.changeLoadingStatus(false))); + constructor(protected vocabularyService: VocabularyService, protected cdr: ChangeDetectorRef, protected layoutService: DynamicFormLayoutService, protected modalService: NgbModal, protected validationService: DynamicFormValidationService, + protected searchService: SearchService, ) { super(vocabularyService, layoutService, validationService); } - /** - * Converts an item from the result list to a `string` to display in the `` field. - */ - formatter = (x: { display: string }) => { - return (typeof x === 'object') ? x.display : x; - }; - - /** - * Converts a stream of text values from the `` element to the stream of the array of items - * to display in the onebox popup. - */ - search = (text$: Observable) => { - return text$.pipe( - merge(this.click$), - debounceTime(300), - distinctUntilChanged(), - tap(() => this.changeSearchingStatus(true)), - switchMap((term) => { - if (term === '' || term.length < this.model.minChars) { - return of({ list: [] }); - } else { - return this.vocabularyService.getVocabularyEntriesByValue( - term, - false, - this.model.vocabularyOptions, - this.pageInfo).pipe( - getFirstSucceededRemoteDataPayload(), - tap(() => this.searchFailed = false), - catchError(() => { - this.searchFailed = true; - return of(buildPaginatedList( - new PageInfo(), - [], - )); - })); - } - }), - map((list: PaginatedList) => list.page), - tap(() => this.changeSearchingStatus(false)), - merge(this.hideSearchingWhenUnsubscribed$), - ); - }; - /** * Initialize the component, setting up the init form value */ ngOnInit() { if (this.model.value) { - this.setCurrentValue(this.model.value, true); + this.setCurrentValue(this.model.value, !this.isSolrSuggest); } - this.vocabulary$ = this.vocabularyService.findVocabularyById(this.model.vocabularyOptions.name).pipe( - getFirstSucceededRemoteDataPayload(), - distinctUntilChanged(), - ); - - this.isHierarchicalVocabulary$ = this.vocabulary$.pipe( - map((result: Vocabulary) => result.hierarchical), - ); - this.subs.push(this.group.get(this.model.id).valueChanges.pipe( filter((value) => this.currentValue !== value)) - .subscribe((value) => { + .subscribe(() => { this.setCurrentValue(this.model.value); })); } /** * Changes the searching status - * @param status - */ - changeSearchingStatus(status: boolean) { - this.searching = status; - this.cdr.detectChanges(); - } - - /** - * Changes the loadingInitialValue status - * @param status + * @param loading */ - changeLoadingInitialValueStatus(status: boolean) { - this.loadingInitialValue = status; + changeLoadingStatus(loading: boolean) { + this.loading = loading; this.cdr.detectChanges(); } - /** - * Checks if configured vocabulary is Hierarchical or not - */ - isHierarchicalVocabulary(): Observable { - return this.isHierarchicalVocabulary$; - } - /** * Update the input value with a FormFieldMetadataValueObject * @param event */ - onInput(event) { - if (!this.model.vocabularyOptions.closed && isNotEmpty(event.target.value)) { + onInput(event: any) { + if (!this.model.vocabularyOptions.closed + && isNotEmpty(event.target.value)) { this.inputValue = new FormFieldMetadataValueObject(event.target.value); } } @@ -229,7 +207,7 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple * @param event The value to emit. */ onBlur(event: Event) { - if (!this.instance.isPopupOpen()) { + if (!this.typeahead.isPopupOpen()) { if (!this.model.vocabularyOptions.closed && isNotEmpty(this.inputValue)) { if (isNotNull(this.inputValue) && this.model.value !== this.inputValue) { this.dispatchUpdate(this.inputValue); @@ -249,6 +227,11 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple this.inputValue = null; } } + + if (this.model.vocabularyOptions.type === 'suggest') { + this.dispatchUpdate(this.currentValue); + } + } /** @@ -272,11 +255,23 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple this.dispatchUpdate(event.item); } + onSelectSuggestedTerm(event: NgbTypeaheadSelectItemEvent) { + const sanitizedTerm = (event.item.term + '').replace('', '').replace('', ''); + const ve: VocabularyEntryDetail = Object.assign({ + display: sanitizedTerm, + value: sanitizedTerm, + selectable: true, + }); + this.inputValue = ve; + this.setCurrentValue(ve); + this.dispatchUpdate(ve); + } + /** * Open modal to show tree for hierarchical vocabulary * @param event The click event fired */ - openTree(event) { + openTree(event: Event) { if (this.model.readOnly) { return; } @@ -304,9 +299,9 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple /** * Callback functions for whenClickOnConfidenceNotAccepted event */ - public whenClickOnConfidenceNotAccepted(confidence: ConfidenceType) { + public whenClickOnConfidenceNotAccepted(_confidence: ConfidenceType) { if (!this.model.readOnly) { - this.click$.next(this.formatter(this.currentValue)); + this.click$.next(this.currentValue); } } @@ -318,10 +313,10 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple setCurrentValue(value: any, init = false): void { let result: string; if (init) { - this.changeLoadingInitialValueStatus(true); + this.changeLoadingStatus(true); this.getInitValueFromModel(true) .subscribe((formValue: FormFieldMetadataValueObject) => { - this.changeLoadingInitialValueStatus(false); + this.changeLoadingStatus(false); this.currentValue = formValue; this.cdr.detectChanges(); }); @@ -335,7 +330,6 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple this.currentValue = result; this.cdr.detectChanges(); } - } ngOnDestroy(): void { @@ -343,5 +337,4 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple .filter((sub) => hasValue(sub)) .forEach((sub) => sub.unsubscribe()); } - } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.component.spec.ts index 86e9d970fb4..0a84e2a641e 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.component.spec.ts @@ -23,6 +23,7 @@ import { APP_DATA_SERVICES_MAP } from '@dspace/core/data-services-map-type'; import { FormFieldModel } from '@dspace/core/shared/form/models/form-field.model'; import { FormFieldMetadataValueObject } from '@dspace/core/shared/form/models/form-field-metadata-value.model'; import { VocabularyService } from '@dspace/core/submission/vocabularies/vocabulary.service'; +import { SearchServiceStub } from '@dspace/core/testing/search-service.stub'; import { createTestComponent } from '@dspace/core/testing/utils.test'; import { VocabularyServiceStub } from '@dspace/core/testing/vocabulary-service.stub'; import { XSRFService } from '@dspace/core/xsrf/xsrf.service'; @@ -39,6 +40,7 @@ import { provideMockActions } from '@ngrx/effects/testing'; import { provideMockStore } from '@ngrx/store/testing'; import { TranslateModule } from '@ngx-translate/core'; import { Observable } from 'rxjs'; +import { SearchService } from 'src/app/shared/search/search.service'; import { environment } from '../../../../../../../environments/environment.test'; import { SubmissionService } from '../../../../../../submission/submission.service'; @@ -141,6 +143,7 @@ describe('DsDynamicRelationGroupComponent test suite', () => { let testFixture: ComponentFixture; let groupFixture: ComponentFixture; let vocabularyServiceStub: any; + let searchServiceStub: any; let modelValue: any; let html; let control1: UntypedFormControl; @@ -152,6 +155,7 @@ describe('DsDynamicRelationGroupComponent test suite', () => { beforeEach(waitForAsync(() => { init(); vocabularyServiceStub = new VocabularyServiceStub(); + searchServiceStub = new SearchServiceStub(); /* TODO make sure these files use mocks instead of real services/components https://github.com/DSpace/dspace-angular/issues/281 */ TestBed.configureTestingModule({ imports: [ @@ -175,6 +179,7 @@ describe('DsDynamicRelationGroupComponent test suite', () => { provideMockStore({ initialState }), provideMockActions(() => new Observable()), { provide: VocabularyService, useValue: vocabularyServiceStub }, + { provide: SearchService, useValue: SearchServiceStub }, { provide: DsDynamicTypeBindRelationService, useClass: DsDynamicTypeBindRelationService }, { provide: SubmissionObjectService, useValue: {} }, { provide: SubmissionService, useValue: {} }, diff --git a/src/app/shared/form/builder/parsers/field-parser.ts b/src/app/shared/form/builder/parsers/field-parser.ts index be849852e50..cf310659118 100644 --- a/src/app/shared/form/builder/parsers/field-parser.ts +++ b/src/app/shared/form/builder/parsers/field-parser.ts @@ -144,6 +144,7 @@ export abstract class FieldParser { controlModel.vocabularyOptions = new VocabularyOptions( this.configData.selectableMetadata[0].controlledVocabulary, this.configData.selectableMetadata[0].closed, + this.configData.selectableMetadata[0].vocabularyType, ); } } diff --git a/src/app/shared/form/form.reducer.ts b/src/app/shared/form/form.reducer.ts index 3137c7e7115..56269c683ba 100644 --- a/src/app/shared/form/form.reducer.ts +++ b/src/app/shared/form/form.reducer.ts @@ -87,6 +87,7 @@ function addFormErrors(state: FormState, action: FormAddError) { fieldIndex: action.payload.fieldIndex, message: action.payload.errorMessage, }; + console.dir(error); const metadata = action.payload.fieldId.replace(/\_/g, '.'); const touched = Object.assign({}, state[formId].touched, { [metadata]: true, diff --git a/src/app/shared/search/search.service.ts b/src/app/shared/search/search.service.ts index 7297e17e604..a047cfc09eb 100644 --- a/src/app/shared/search/search.service.ts +++ b/src/app/shared/search/search.service.ts @@ -1,5 +1,6 @@ /* eslint-disable max-classes-per-file */ import { Injectable } from '@angular/core'; +import { DomSanitizer } from '@angular/platform-browser'; import { RemoteDataBuildService } from '@dspace/core/cache/builders/remote-data-build.service'; import { BaseDataService } from '@dspace/core/data/base/base-data.service'; import { DSpaceObjectDataService } from '@dspace/core/data/dspace-object-data.service'; @@ -20,6 +21,7 @@ import { HALEndpointService } from '@dspace/core/shared/hal-endpoint.service'; import { ListableObject } from '@dspace/core/shared/object-collection/listable-object.model'; import { getFirstCompletedRemoteData, + getFirstSucceededRemoteDataPayload, getRemoteDataPayload, } from '@dspace/core/shared/operators'; import { AppliedFilter } from '@dspace/core/shared/search/models/applied-filter.model'; @@ -52,6 +54,7 @@ import { import { SearchConfigurationService } from './search-configuration.service'; import { getSearchResultFor } from './search-result-element-decorator'; +import { SuggestionEntry } from './suggestion-entry.model'; /** * A limited data service implementation for the 'discover' endpoint @@ -113,6 +116,7 @@ export class SearchService { private paginationService: PaginationService, private searchConfigurationService: SearchConfigurationService, private angulartics2: Angulartics2, + private sanitizer: DomSanitizer, ) { this.searchDataService = new SearchDataService(); } @@ -143,6 +147,18 @@ export class SearchService { } } + getSuggestEndpoint(searchOptions?: PaginatedSearchOptions): Observable { + // TODO: HAL link to 'suggest' endpoint does not exist yet, need to fix this + return this.halService.getEndpoint('discover').pipe( + map((url: string) => { + if (hasValue(searchOptions)) { + return (searchOptions as PaginatedSearchOptions).toRestUrl(url); + } else { + return url; + } + }), + ); + } getEndpoint(searchOptions?: PaginatedSearchOptions): Observable { return this.halService.getEndpoint(this.searchLinkPath).pipe( map((url: string) => { @@ -336,6 +352,38 @@ export class SearchService { ); } + /** + * Get Solr suggestions as serialised JSON, for the given search query and dictionary name + * @param {string} query the search query + * @param {string} dictionary the configured dictionary in solrconfig.xml + * @returns serialised JSON of Solr term suggestions + */ + getSuggestionsFor(query: string, dictionary: string): Observable { + return this.getSuggestEndpoint().pipe( + take(1), + switchMap((baseUrl: string) => { + const href = new URLCombiner(baseUrl, 'suggest').toString(); + const request = new this.request( + this.requestService.generateRequestId(), + href, null, + { + params: { + dict: dictionary, + q: query, + }, + }); + this.requestService.send(request, false); + return this.rdb.buildFromRequestUUID(request.uuid).pipe( + getFirstSucceededRemoteDataPayload(), + map((data: any) => data.suggest[dictionary][query].suggestions + ?.map(((suggestion: any) => new SuggestionEntry( + new DOMParser().parseFromString(suggestion.term, 'text/html') + .body.textContent, + this.sanitizer.bypassSecurityTrustHtml(suggestion.term), + suggestion.weight))))); + })); + } + /** * Requests the current view mode based on the current URL * @returns {Observable} The current view mode diff --git a/src/app/shared/search/suggestion-entry.model.ts b/src/app/shared/search/suggestion-entry.model.ts new file mode 100644 index 00000000000..a5648ff51c3 --- /dev/null +++ b/src/app/shared/search/suggestion-entry.model.ts @@ -0,0 +1,54 @@ +import { SafeHtml } from '@angular/platform-browser'; +import { typedObject } from '@dspace/core/cache/builders/build-decorators'; +import { OtherInformation } from '@dspace/core/shared/form/models/form-field-metadata-value.model'; +import { GenericConstructor } from '@dspace/core/shared/generic-constructor'; +import { ListableObject } from '@dspace/core/shared/object-collection/listable-object.model'; +import { ResourceType } from '@dspace/core/shared/resource-type'; +import { excludeFromEquals } from '@dspace/core/utilities/equals.decorators'; +import { isNotEmpty } from '@dspace/shared/utils/empty.util'; +import { autoserialize } from 'cerialize'; + + +@typedObject +export class SuggestionEntry extends ListableObject { + static type = new ResourceType('suggestionEntry'); + + /** + * The display value of this suggestion entry + */ + @autoserialize + display: SafeHtml; + + /** + * The value of this suggestion entry + */ + @autoserialize + value: string; + + @excludeFromEquals + @autoserialize + otherInformation: OtherInformation; + + /** + * This method checks if entry has related information object + * + * @return boolean + */ + hasOtherInformation(): boolean { + return isNotEmpty(this.otherInformation); + } + + /** + * Method that returns as which type of object this object should be rendered + */ + getRenderTypes(): (string | GenericConstructor)[] { + return [this.constructor as GenericConstructor]; + } + + constructor(value: string, display: SafeHtml, weight: string) { + super(); + this.value = value; + this.display = display; + this.otherInformation = { weight }; + } +} diff --git a/src/assets/i18n/bn.json5 b/src/assets/i18n/bn.json5 index 0e302690b37..eefc1791d17 100644 --- a/src/assets/i18n/bn.json5 +++ b/src/assets/i18n/bn.json5 @@ -2081,6 +2081,8 @@ "form.other-information.orcid": "ORCID", + "form.other-information.weight": "ওজন", + "form.remove": "অপসারণ", "form.save": "সংরক্ষণ", diff --git a/src/assets/i18n/ca.json5 b/src/assets/i18n/ca.json5 index a984fb254c7..8fbed8f4a22 100644 --- a/src/assets/i18n/ca.json5 +++ b/src/assets/i18n/ca.json5 @@ -3162,6 +3162,9 @@ // "form.other-information.orcid": "ORCID", "form.other-information.orcid": "ORCID", + // "form.other-information.weight": "Weight", + "form.other-information.weight": "Pes", + // "form.remove": "Remove", "form.remove": "Eliminar", diff --git a/src/assets/i18n/cs.json5 b/src/assets/i18n/cs.json5 index 544c1a63e29..18e68f3cf06 100644 --- a/src/assets/i18n/cs.json5 +++ b/src/assets/i18n/cs.json5 @@ -3124,6 +3124,9 @@ // "form.other-information.orcid": "ORCID", "form.other-information.orcid": "ORCID", + // "form.other-information.weight": "Weight", + "form.other-information.weight": "Hmotnost", + // "form.remove": "Remove", "form.remove": "Odstranit", diff --git a/src/assets/i18n/de.json5 b/src/assets/i18n/de.json5 index 462b91bfd59..cd2e038ef28 100644 --- a/src/assets/i18n/de.json5 +++ b/src/assets/i18n/de.json5 @@ -3159,6 +3159,9 @@ // "form.other-information.orcid": "ORCID", "form.other-information.orcid": "ORCID", + // "form.other-information.weight": "Weight", + "form.other-information.weight": "Gewicht", + // "form.remove": "Remove", "form.remove": "Entfernen", diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index c3bdb7b0374..58b96763ece 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -2137,6 +2137,8 @@ "form.other-information.orcid": "ORCID", + "form.other-information.weight": "Weight", + "form.remove": "Remove", "form.save": "Save",