Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UFAL/Autocomplete enhancement #718

Merged
merged 7 commits into from
Oct 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions src/app/core/data/metadata-value-data.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { RequestParam } from '../cache/models/request-param.model';
import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
import { MetadataValue } from '../metadata/metadata-value.model';
import { VocabularyEntry } from '../submission/vocabularies/models/vocabulary-entry.model';
import { isNotEmpty } from '../../shared/empty.util';
import { isEmpty, isNotEmpty } from '../../shared/empty.util';
import { EMPTY } from 'rxjs';
import { BaseDataService } from './base/base-data.service';
import { dataService } from './base/data-service.decorator';
Expand Down Expand Up @@ -59,22 +59,22 @@ export class MetadataValueDataService extends BaseDataService<MetadataValue> imp
* Retrieve the MetadataValue object inside Vocabulary object body
*/
findByMetadataNameAndByValue(metadataName, term = ''): Observable<PaginatedList<MetadataValue>> {
const metadataFields = metadataName.split('.');
const metadataFields = metadataName?.split('.');

const schemaRP = new RequestParam('schema', '');
const elementRP = new RequestParam('element', '');
const qualifierRP = new RequestParam('qualifier', '');
const termRP = new RequestParam('searchValue', term);

// schema and element are mandatory - cannot be empty
if (!isNotEmpty(metadataFields[0]) && !isNotEmpty(metadataFields[1])) {
if (isEmpty(metadataFields?.[0]) && isEmpty(metadataFields?.[1])) {
return EMPTY;
}

// add value to the request params
schemaRP.fieldValue = metadataFields[0];
elementRP.fieldValue = metadataFields[1];
qualifierRP.fieldValue = isNotEmpty(metadataFields[2]) ? metadataFields[2] : null;
schemaRP.fieldValue = metadataFields?.[0];
vidiecan marked this conversation as resolved.
Show resolved Hide resolved
elementRP.fieldValue = metadataFields?.[1];
qualifierRP.fieldValue = isNotEmpty(metadataFields?.[2]) ? metadataFields?.[2] : null;

const optionParams = Object.assign(new FindListOptions(), {}, {
searchParams: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,19 @@ import { createTestComponent } from '../../../../../testing/utils.test';
import { DsDynamicAutocompleteComponent } from './ds-dynamic-autocomplete.component';
import { DsDynamicAutocompleteModel } from './ds-dynamic-autocomplete.model';
import { MetadataValueDataService } from '../../../../../../core/data/metadata-value-data.service';
import { of as observableOf } from 'rxjs';
import { of, of as observableOf } from 'rxjs';
import { VocabularyEntry } from '../../../../../../core/submission/vocabularies/models/vocabulary-entry.model';
import { MockMetadataValueService } from '../../../../../testing/metadata-value-data-service.mock';
import { LookupRelationService } from '../../../../../../core/data/lookup-relation.service';
import { MockLookupRelationService } from '../../../../../testing/lookup-relation-service.mock';
import { getMockRequestService } from '../../../../../mocks/request.service.mock';
import { HALEndpointServiceStub } from '../../../../../testing/hal-endpoint-service.stub';
import { getMockRemoteDataBuildService } from '../../../../../mocks/remote-data-build.service.mock';
import { RequestService } from '../../../../../../core/data/request.service';
import { HALEndpointService } from '../../../../../../core/shared/hal-endpoint.service';
import { RemoteDataBuildService } from '../../../../../../core/cache/builders/remote-data-build.service';
import { ConfigurationDataService } from '../../../../../../core/data/configuration-data.service';
import {TranslateModule} from '@ngx-translate/core';

let AUT_TEST_GROUP;
let AUT_TEST_MODEL_CONFIG;
Expand Down Expand Up @@ -56,6 +64,12 @@ describe('DsDynamicAutocompleteComponent test suite', () => {
const mockMetadataValueService = new MockMetadataValueService();
const vocabularyServiceStub = new VocabularyServiceStub();
const mockLookupRelationService = new MockLookupRelationService();
const requestService = getMockRequestService();
const halService = Object.assign(new HALEndpointServiceStub('url'));
const rdbService = getMockRemoteDataBuildService();
const configurationServiceSpy = jasmine.createSpyObj('configurationService', {
findByPropertyName: of('hdl'),
});
init();
TestBed.configureTestingModule({
imports: [
Expand All @@ -64,6 +78,7 @@ describe('DsDynamicAutocompleteComponent test suite', () => {
FormsModule,
NgbModule,
ReactiveFormsModule,
TranslateModule.forRoot(),
],
declarations: [
DsDynamicAutocompleteComponent,
Expand All @@ -76,7 +91,11 @@ describe('DsDynamicAutocompleteComponent test suite', () => {
{ provide: VocabularyService, useValue: vocabularyServiceStub },
{ provide: DynamicFormLayoutService, useValue: mockDynamicFormLayoutService },
{ provide: DynamicFormValidationService, useValue: mockDynamicFormValidationService },
{ provide: LookupRelationService, useValue: mockLookupRelationService}
{ provide: LookupRelationService, useValue: mockLookupRelationService},
{ provide: RequestService, useValue: requestService },
{ provide: HALEndpointService, useValue: halService },
{ provide: RemoteDataBuildService, useValue: rdbService },
{ provide: ConfigurationDataService, useValue: configurationServiceSpy }
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,53 @@
import { ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output, TemplateRef, ViewChild } from '@angular/core';
import { FormGroup } from '@angular/forms';
import { DynamicTagModel } from '../tag/dynamic-tag.model';
import { NgbTypeahead, NgbTypeaheadSelectItemEvent } from '@ng-bootstrap/ng-bootstrap';
import { Observable, of as observableOf } from 'rxjs';
import { BehaviorSubject, Observable, of as observableOf } from 'rxjs';
import { PageInfo } from '../../../../../../core/shared/page-info.model';
import { VocabularyService } from '../../../../../../core/submission/vocabularies/vocabulary.service';
import { DynamicFormLayoutService, DynamicFormValidationService } from '@ng-dynamic-forms/core';
import { catchError, debounceTime, distinctUntilChanged, map, merge, switchMap, tap } from 'rxjs/operators';
import { buildPaginatedList } from '../../../../../../core/data/paginated-list.model';
import { isEmpty, isNotEmpty } from '../../../../../empty.util';
import { buildPaginatedList, PaginatedList } from '../../../../../../core/data/paginated-list.model';
import { isEmpty, isNotEmpty, isNull } from '../../../../../empty.util';
import { DsDynamicTagComponent } from '../tag/dynamic-tag.component';
import { MetadataValueDataService } from '../../../../../../core/data/metadata-value-data.service';
import { FormFieldMetadataValueObject } from '../../../models/form-field-metadata-value.model';
import { LookupRelationService } from '../../../../../../core/data/lookup-relation.service';
import {
AUTOCOMPLETE_CUSTOM_JSON_PREFIX,
AUTOCOMPLETE_CUSTOM_SOLR_PREFIX,
DsDynamicAutocompleteModel
} from './ds-dynamic-autocomplete.model';
import { getFirstSucceededRemoteDataPayload } from '../../../../../../core/shared/operators';
import { GetRequest } from '../../../../../../core/data/request.models';
import { HttpOptions } from '../../../../../../core/dspace-rest/dspace-rest.service';
import { HttpParams } from '@angular/common/http';
import { RequestService } from '../../../../../../core/data/request.service';
import { RemoteDataBuildService } from '../../../../../../core/cache/builders/remote-data-build.service';
import { HALEndpointService } from '../../../../../../core/shared/hal-endpoint.service';
import { RemoteData } from '../../../../../../core/data/remote-data';
import { VocabularyEntry } from '../../../../../../core/submission/vocabularies/models/vocabulary-entry.model';
import { ConfigurationDataService } from '../../../../../../core/data/configuration-data.service';
import { CANONICAL_PREFIX_KEY } from '../../../../../handle.service';
import { ConfigurationProperty } from '../../../../../../core/shared/configuration-property.model';
import { DsDynamicAutocompleteService } from './ds-dynamic-autocomplete.service';
import { TranslateService } from '@ngx-translate/core';

/**
* Prefix for custom autocomplete definition from the `submission-forms.xml`.
* <input-type autocomplete-custom="solr-handle_title_ac">autocomplete</input-type>
*/
const AUTOCOMPLETE_CUSTOM_HANDLE_TITLE = 'solr-handle_title_ac';
vidiecan marked this conversation as resolved.
Show resolved Hide resolved

/**
* Prefix for custom autocomplete definition from the `submission-forms.xml`.
* <input-type autocomplete-custom="son_static-iso_langs.json">autocomplete</input-type>
*/
const AUTOCOMPLETE_CUSTOM_LANGUAGE_JSON = 'json_static-iso_langs.json';

/**
* The suggestion has a `:` in the result value as a separator.
*/
const AUTOCOMPLETE_CUSTOM_VALUE_SEPARATOR = ':';

/**
* Component representing a autocomplete input field.
Expand All @@ -26,7 +61,7 @@ export class DsDynamicAutocompleteComponent extends DsDynamicTagComponent implem

@Input() bindId = true;
@Input() group: FormGroup;
@Input() model: DynamicTagModel;
@Input() model: DsDynamicAutocompleteModel;

@Output() blur: EventEmitter<any> = new EventEmitter<any>();
@Output() change: EventEmitter<any> = new EventEmitter<any>();
Expand All @@ -35,19 +70,28 @@ export class DsDynamicAutocompleteComponent extends DsDynamicTagComponent implem
@ViewChild('instance') instance: NgbTypeahead;

hasAuthority: boolean;
isSponsorInputType = false;

searching = false;
searchFailed = false;
currentValue: any;
public pageInfo: PageInfo;

/**
* Handle canonical prefix loaded from the cfg `handle.canonical.prefix`.
*/
handlePrefix: BehaviorSubject<string> = new BehaviorSubject<string>(null);

constructor(protected vocabularyService: VocabularyService,
protected cdr: ChangeDetectorRef,
protected layoutService: DynamicFormLayoutService,
protected validationService: DynamicFormValidationService,
protected metadataValueService: MetadataValueDataService,
protected lookupRelationService: LookupRelationService
protected lookupRelationService: LookupRelationService,
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected halService: HALEndpointService,
protected configurationService: ConfigurationDataService,
protected translateService: TranslateService
) {
super(vocabularyService, cdr, layoutService, validationService);
}
Expand All @@ -62,6 +106,17 @@ export class DsDynamicAutocompleteComponent extends DsDynamicTagComponent implem
}
this.setCurrentValue(this.model.value, true);
}

// Load handle prefix if autocomplete custom is `solr-handle_title_ac` and it is not loaded yet
if (this.model?.autocompleteCustom === AUTOCOMPLETE_CUSTOM_HANDLE_TITLE && isNull(this.handlePrefix.value)) {
// Load configuration property for handle prefix
this.configurationService.findByPropertyName(CANONICAL_PREFIX_KEY)
.pipe(getFirstSucceededRemoteDataPayload())
.subscribe((handlePrefixCfgProp: ConfigurationProperty) => {
const handlePrefix = handlePrefixCfgProp?.values?.[0];
this.handlePrefix.next(handlePrefix);
});
}
}

/**
Expand All @@ -87,6 +142,12 @@ export class DsDynamicAutocompleteComponent extends DsDynamicTagComponent implem
* @param updateValue raw suggestion.
*/
updateModel(updateValue) {
if (this.model?.autocompleteCustom === AUTOCOMPLETE_CUSTOM_HANDLE_TITLE) {
const handle_title = updateValue.display.split(AUTOCOMPLETE_CUSTOM_VALUE_SEPARATOR);
updateValue.display = this.handlePrefix.value + handle_title[0];
updateValue.value = this.handlePrefix.value + handle_title[0];
}

this.dispatchUpdate(updateValue.display);
}

Expand Down Expand Up @@ -137,6 +198,10 @@ export class DsDynamicAutocompleteComponent extends DsDynamicTagComponent implem
* @param suggestion
*/
suggestionFormatter = (suggestion: TemplateRef<any>) => {
if (this.model.autocompleteCustom === AUTOCOMPLETE_CUSTOM_LANGUAGE_JSON) {
// Language suggestion has a special format - ISO code and language name
return DsDynamicAutocompleteService.pretifyLanguageSuggestion(suggestion, this.translateService);
}
// @ts-ignore
return suggestion.display;
};
Expand All @@ -155,22 +220,76 @@ export class DsDynamicAutocompleteComponent extends DsDynamicTagComponent implem
if (term === '' || term.length < this.model.minChars) {
return observableOf({ list: [] });
} else {
// metadataValue request
const response = this.metadataValueService.findByMetadataNameAndByValue(this.model?.metadataFields?.pop(), term);
return response.pipe(
tap(() => this.searchFailed = false),
catchError((error) => {
this.searchFailed = true;
return observableOf(buildPaginatedList(
new PageInfo(),
[]
));
}));
// Custom suggestion request
if (this.model.autocompleteCustom) {
if (this.model.autocompleteCustom.startsWith(AUTOCOMPLETE_CUSTOM_SOLR_PREFIX) ||
this.model.autocompleteCustom.startsWith(AUTOCOMPLETE_CUSTOM_JSON_PREFIX)) {
return this.getCustomSuggestions(this.model.autocompleteCustom, term)
.pipe(getFirstSucceededRemoteDataPayload(),
map((list: PaginatedList<VocabularyEntry>) => {
return this.formatVocabularyEntryList(list);
}),
tap(() => this.searchFailed = false),
catchError(() => {
return this.onSearchErrorVocabularyEntries();
}));
}
} else {
// MetadataValue request
const response = this.metadataValueService.findByMetadataNameAndByValue(
this.model?.metadataFields?.[this.model?.metadataFields?.length - 1], term);
return response.pipe(
tap(() => this.searchFailed = false),
catchError(() => {
return this.onSearchErrorVocabularyEntries();
}));
}
}
}),
map((list: any) => {
return list.page;
}),
tap(() => this.changeSearchingStatus(false)),
merge(this.hideSearchingWhenUnsubscribed));

/**
* If this model is defined to fetch suggestions from a custom endpoint and solr index, fetch them.
*/
getCustomSuggestions(autocompleteCustom: string, term: string): Observable<RemoteData<any>> {
const options: HttpOptions = Object.create({});
options.params = new HttpParams({ fromString: 'autocompleteCustom=' + autocompleteCustom + '&searchValue=' + term });

const requestId = this.requestService.generateRequestId();
const url = this.halService.getRootHref() + '/suggestions';
const getRequest = new GetRequest(requestId, url, null, options);
this.requestService.send(getRequest);

return this.rdbService.buildFromRequestUUID(requestId);
}

/**
* Format the vocabulary entry list from `/suggestions` endpoint, because it is not a standard vocabulary endpoint.
*/
formatVocabularyEntryList(list: PaginatedList<VocabularyEntry>): PaginatedList<VocabularyEntry> {
const vocabularyEntryList: VocabularyEntry[] = [];
list.page.forEach((rawVocabularyEntry: VocabularyEntry) => {
const voc: VocabularyEntry = new VocabularyEntry();
voc.display = rawVocabularyEntry.display;
voc.value = rawVocabularyEntry.value;
vocabularyEntryList.push(voc);
});
list.page = vocabularyEntryList;
return list;
}

/**
* Return empty list on error when fetching suggestions.
*/
onSearchErrorVocabularyEntries() {
this.searchFailed = true;
return observableOf(buildPaginatedList(
new PageInfo(),
[]
));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@ import { isEmpty } from '../../../../../empty.util';

export const DYNAMIC_FORM_CONTROL_TYPE_AUTOCOMPLETE = 'AUTOCOMPLETE';
export const AUTOCOMPLETE_COMPLEX_PREFIX = 'autocomplete_in_complex_input';
export const DEFAULT_MIN_CHARS_TO_AUTOCOMPLETE = 3;
export const DEFAULT_MIN_CHARS_TO_AUTOCOMPLETE = 1;
export const AUTOCOMPLETE_CUSTOM_SOLR_PREFIX = 'solr-';
export const AUTOCOMPLETE_CUSTOM_JSON_PREFIX = 'json_static-';

/**
* Configuration for the DsDynamicAutocompleteModel.
*/
export interface DsDynamicAutocompleteModelConfig extends DsDynamicInputModelConfig {
minChars?: number;
value?: any;
autocompleteCustom?: string;
}

/**
Expand All @@ -22,6 +25,7 @@ export class DsDynamicAutocompleteModel extends DsDynamicInputModel {

@serializable() minChars: number;
@serializable() readonly type: string = DYNAMIC_FORM_CONTROL_TYPE_AUTOCOMPLETE;
@serializable() autocompleteCustom: string;

constructor(config: DsDynamicAutocompleteModelConfig, layout?: DynamicFormControlLayout) {

Expand All @@ -35,5 +39,6 @@ export class DsDynamicAutocompleteModel extends DsDynamicInputModel {
this.minChars = config.minChars || DEFAULT_MIN_CHARS_TO_AUTOCOMPLETE;
// if value is not defined in the configuration -> value is empty
this.value = config.value || [];
this.autocompleteCustom = config.autocompleteCustom;
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { take } from 'rxjs/operators';
import {isEmpty } from '../../../../../empty.util';
import {TranslateService} from '@ngx-translate/core';

/**
* Util methods for the DsAutocompleteComponent.
*/
export class DsDynamicAutocompleteService {

static pretifySuggestion(fundingProjectCode, fundingName, translateService) {
static prettifySponsorSuggestion(fundingProjectCode, fundingName, translateService) {
if (isEmpty(fundingProjectCode) || isEmpty(fundingName)) {
throw (new Error('The suggestion returns wrong data!'));
}
Expand All @@ -26,4 +27,14 @@ export class DsDynamicAutocompleteService {

return (fundingCode + ': ').bold() + fundingProjectCode + '<br>' + (projectName + ': ').bold() + fundingName;
}

static pretifyLanguageSuggestion(suggestion, translateService: TranslateService) {
// fetch ISO message
const isoMessage = translateService.instant('autocomplete.suggestion.language.iso');
// fetch language message
const languageMessage = translateService.instant('autocomplete.suggestion.language.title');

return (isoMessage + ': ').bold() + suggestion?.value + '<br>' + (languageMessage + ': ').bold() +
suggestion?.display;
}
}
Loading
Loading