diff --git a/config/config.example.yml b/config/config.example.yml index 978ea3b07c7..660b23131a0 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -327,15 +327,6 @@ item: # The maximum number of values for repeatable metadata to show in the full item metadataLimit: 20 -# When the search results are retrieved, for each item type the metadata with a valid authority value are inspected. -# Referenced items will be fetched with a find all by id strategy to avoid individual rest requests -# to efficiently display the search results. -followAuthorityMetadata: - - type: Publication - metadata: dc.contributor.author - - type: Product - metadata: dc.contributor.author - # Collection Page Config collection: edit: @@ -517,3 +508,19 @@ addToAnyPlugin: title: DSpace CRIS 7 demo # The link to be shown in the shared post, if different from document.location.origin (optional) # link: https://dspacecris7.4science.cloud/ + +# When the search results are retrieved, for each item type the metadata with a valid authority value are inspected. +# Referenced items will be fetched with a find all by id strategy to avoid individual rest requests +# to efficiently display the search results. +followAuthorityMetadata: + - type: Publication + metadata: dc.contributor.author + - type: Product + metadata: dc.contributor.author + +# The maximum number of item to process when following authority metadata values. +followAuthorityMaxItemLimit: 100 + +# The maximum number of metadata values to process for each metadata key +# when following authority metadata values. +followAuthorityMetadataValuesLimit: 5 diff --git a/src/app/core/browse/search-manager.ts b/src/app/core/browse/search-manager.ts index c8946037304..5afc68c4eea 100644 --- a/src/app/core/browse/search-manager.ts +++ b/src/app/core/browse/search-manager.ts @@ -113,7 +113,8 @@ export class SearchManager { }) .filter((item) => hasValue(item)); - const uuidList = this.extractUUID(items, environment.followAuthorityMetadata); + const uuidList = this.extractUUID(items, environment.followAuthorityMetadata, environment.followAuthorityMaxItemLimit); + return uuidList.length > 0 ? this.itemService.findAllById(uuidList).pipe( getFirstCompletedRemoteData(), map(data => { @@ -126,20 +127,20 @@ export class SearchManager { ) : of(null); } - protected extractUUID(items: Item[], metadataToFollow: FollowAuthorityMetadata[]): string[] { + protected extractUUID(items: Item[], metadataToFollow: FollowAuthorityMetadata[], numberOfElementsToReturn?: number): string[] { const uuidMap = {}; items.forEach((item) => { metadataToFollow.forEach((followMetadata: FollowAuthorityMetadata) => { if (item.entityType === followMetadata.type) { if (isArray(followMetadata.metadata)) { - followMetadata.metadata.forEach((metadata) => { - Metadata.all(item.metadata, metadata) + followMetadata.metadata.forEach((metadata) => { + Metadata.all(item.metadata, metadata, null, environment.followAuthorityMetadataValuesLimit) .filter((metadataValue: MetadataValue) => Metadata.hasValidItemAuthority(metadataValue.authority)) .forEach((metadataValue: MetadataValue) => uuidMap[metadataValue.authority] = metadataValue); }); } else { - Metadata.all(item.metadata, followMetadata.metadata) + Metadata.all(item.metadata, followMetadata.metadata, null, environment.followAuthorityMetadataValuesLimit) .filter((metadataValue: MetadataValue) => Metadata.hasValidItemAuthority(metadataValue.authority)) .forEach((metadataValue: MetadataValue) => uuidMap[metadataValue.authority] = metadataValue); } @@ -147,6 +148,10 @@ export class SearchManager { }); }); + if (hasValue(numberOfElementsToReturn) && numberOfElementsToReturn > 0) { + return Object.keys(uuidMap).slice(0, numberOfElementsToReturn); + } + return Object.keys(uuidMap); } } diff --git a/src/app/core/browse/search.manager.spec.ts b/src/app/core/browse/search.manager.spec.ts index da9097be76f..e319a22933e 100644 --- a/src/app/core/browse/search.manager.spec.ts +++ b/src/app/core/browse/search.manager.spec.ts @@ -182,6 +182,11 @@ describe('SearchManager', () => { const uuidList = (service as any).extractUUID([firstPublication, firstPublication], [{type: 'Publication', metadata: ['dc.contributor.author']}]); expect(uuidList).toEqual([validAuthority]); }); + + it('should limit the number of extracted uuids', () => { + const uuidList = (service as any).extractUUID([firstPublication, secondPublication, invalidAuthorityPublication], [{type: 'Publication', metadata: ['dc.contributor.author']}], 2); + expect(uuidList.length).toBe(2); + }); }); }); diff --git a/src/app/core/shared/dspace-object.model.ts b/src/app/core/shared/dspace-object.model.ts index c0cc8d9c214..717988b4aae 100644 --- a/src/app/core/shared/dspace-object.model.ts +++ b/src/app/core/shared/dspace-object.model.ts @@ -115,6 +115,18 @@ export class DSpaceObject extends ListableObject implements CacheableObject { return Metadata.all(this.metadata, keyOrKeys, valueFilter); } + /** + * Gets all matching metadata in this DSpaceObject, up to a limit. + * + * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. + * @param {number} limit The maximum number of results to return. + * @param {MetadataValueFilter} valueFilter The value filter to use. If unspecified, no filtering will be done. + * @returns {MetadataValue[]} the matching values or an empty array. + */ + limitedMetadata(keyOrKeys: string | string[], limit: number, valueFilter?: MetadataValueFilter): MetadataValue[] { + return Metadata.all(this.metadata, keyOrKeys, valueFilter, limit); + } + /** * Like [[allMetadata]], but only returns string values. * diff --git a/src/app/core/shared/metadata.utils.spec.ts b/src/app/core/shared/metadata.utils.spec.ts index a80e482f24f..f4348723001 100644 --- a/src/app/core/shared/metadata.utils.spec.ts +++ b/src/app/core/shared/metadata.utils.spec.ts @@ -44,11 +44,11 @@ const multiViewModelList = [ { key: 'foo', ...bar, order: 0 } ]; -const testMethod = (fn, resultKind, mapOrMaps, keyOrKeys, expected, filter?) => { +const testMethod = (fn, resultKind, mapOrMaps, keyOrKeys, expected, filter?, limit?: number) => { const keys = keyOrKeys instanceof Array ? keyOrKeys : [keyOrKeys]; describe('and key' + (keys.length === 1 ? (' ' + keys[0]) : ('s ' + JSON.stringify(keys))) + ' with ' + (isUndefined(filter) ? 'no filter' : 'filter ' + JSON.stringify(filter)), () => { - const result = fn(mapOrMaps, keys, filter); + const result = fn(mapOrMaps, keys, filter, limit); let shouldReturn; if (resultKind === 'boolean') { shouldReturn = expected; @@ -56,7 +56,8 @@ const testMethod = (fn, resultKind, mapOrMaps, keyOrKeys, expected, filter?) => shouldReturn = 'undefined'; } else if (expected instanceof Array) { shouldReturn = 'an array with ' + expected.length + ' ' + (expected.length > 1 ? 'ordered ' : '') - + resultKind + (expected.length !== 1 ? 's' : ''); + + resultKind + (expected.length !== 1 ? 's' : '') + + (isUndefined(limit) ? '' : ' (limited to ' + limit + ')'); } else { shouldReturn = 'a ' + resultKind; } @@ -297,4 +298,12 @@ describe('Metadata', () => { }); + describe('all method with limit', () => { + const testAllWithLimit = (mapOrMaps, keyOrKeys, expected, limit) => + testMethod(Metadata.all, 'value', mapOrMaps, keyOrKeys, expected, undefined, limit); + + describe('with multiMap and limit', () => { + testAllWithLimit(multiMap, 'dc.title', [dcTitle1], 1); + }); + }); }); diff --git a/src/app/core/shared/metadata.utils.ts b/src/app/core/shared/metadata.utils.ts index f6a7fb4fd00..ae5f85f582a 100644 --- a/src/app/core/shared/metadata.utils.ts +++ b/src/app/core/shared/metadata.utils.ts @@ -37,10 +37,11 @@ export class Metadata { * checked in order, and only values from the first with at least one match will be returned. * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above. * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. + * @param {number} limit The maximum number of values to return. If unspecified, all matching values will be returned. * @returns {MetadataValue[]} the matching values or an empty array. */ public static all(mapOrMaps: MetadataMapInterface | MetadataMapInterface[], keyOrKeys: string | string[], - filter?: MetadataValueFilter): MetadataValue[] { + filter?: MetadataValueFilter, limit?: number): MetadataValue[] { const mdMaps: MetadataMapInterface[] = mapOrMaps instanceof Array ? mapOrMaps : [mapOrMaps]; const matches: MetadataValue[] = []; for (const mdMap of mdMaps) { @@ -50,6 +51,9 @@ export class Metadata { for (const candidate of candidates) { if (Metadata.valueMatches(candidate as MetadataValue, filter)) { matches.push(candidate as MetadataValue); + if (hasValue(limit) && matches.length >= limit) { + return matches; + } } } } diff --git a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html index 0fd99735946..e38a8baf492 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html +++ b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html @@ -22,11 +22,20 @@

) {{'mydspace.results.no-authors' | translate}} - - ; - + + + ; + + + + + ; + + diff --git a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.spec.ts b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.spec.ts index 24b57dbfbc3..3e6941f4fd7 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.spec.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.spec.ts @@ -12,6 +12,7 @@ import { TranslateLoaderMock } from '../../../mocks/translate-loader.mock'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { VarDirective } from '../../../utils/var.directive'; import { APP_CONFIG } from '../../../../../config/app-config.interface'; +import { TruncatableService } from '../../../truncatable/truncatable.service'; let component: ItemListPreviewComponent; let fixture: ComponentFixture; @@ -80,6 +81,10 @@ const enviromentNoThumbs = { } }; +const truncatableServiceStub: any = { + isCollapsed: (id: number) => observableOf(true), +}; + describe('ItemListPreviewComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ @@ -95,7 +100,8 @@ describe('ItemListPreviewComponent', () => { declarations: [ItemListPreviewComponent, TruncatePipe, VarDirective], providers: [ { provide: 'objectElementProvider', useValue: { mockItemWithAuthorAndDate }}, - { provide: APP_CONFIG, useValue: environmentUseThumbs } + { provide: APP_CONFIG, useValue: environmentUseThumbs }, + { provide: TruncatableService, useValue: truncatableServiceStub }, ], schemas: [NO_ERRORS_SCHEMA] @@ -198,6 +204,33 @@ describe('ItemListPreviewComponent', () => { expect(entityField).toBeNull(); }); }); + + + describe('When truncatable section is collapsed', () => { + beforeEach(() => { + component.isCollapsed$ = observableOf(true); + component.item = mockItemWithAuthorAndDate; + fixture.detectChanges(); + }); + + it('should show limitedMetadata', () => { + const authorElements = fixture.debugElement.queryAll(By.css('span.item-list-authors ds-metadata-link-view')); + expect(authorElements.length).toBe(mockItemWithAuthorAndDate.limitedMetadata(component.authorMetadata, component.authorMetadataLimit).length); + }); + }); + + describe('When truncatable section is expanded', () => { + beforeEach(() => { + component.isCollapsed$ = observableOf(false); + component.item = mockItemWithAuthorAndDate; + fixture.detectChanges(); + }); + + it('should show allMetadata', () => { + const authorElements = fixture.debugElement.queryAll(By.css('span.item-list-authors ds-metadata-link-view')); + expect(authorElements.length).toBe(mockItemWithAuthorAndDate.allMetadata(component.authorMetadata).length); + }); + }); }); describe('ItemListPreviewComponent', () => { @@ -215,7 +248,8 @@ describe('ItemListPreviewComponent', () => { declarations: [ItemListPreviewComponent, TruncatePipe], providers: [ {provide: 'objectElementProvider', useValue: {mockItemWithAuthorAndDate}}, - {provide: APP_CONFIG, useValue: enviromentNoThumbs} + {provide: APP_CONFIG, useValue: enviromentNoThumbs}, + { provide: TruncatableService, useValue: truncatableServiceStub }, ], schemas: [NO_ERRORS_SCHEMA] diff --git a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts index a4049936deb..de53fef63dc 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts @@ -12,6 +12,8 @@ import { } from '../../../../submission/sections/detect-duplicate/models/duplicate-detail-metadata.model'; import { parseISO, differenceInDays, differenceInMilliseconds } from 'date-fns'; import { environment } from '../../../../../environments/environment'; +import { TruncatableService } from '../../../truncatable/truncatable.service'; +import { Observable } from 'rxjs'; /** * This component show metadata for the given item object in the list view. @@ -78,9 +80,14 @@ export class ItemListPreviewComponent implements OnInit { authorMetadata = environment.searchResult.authorMetadata; + authorMetadataLimit = environment.followAuthorityMetadataValuesLimit; + + isCollapsed$: Observable; + constructor( @Inject(APP_CONFIG) protected appConfig: AppConfig, public dsoNameService: DSONameService, + public truncateService: TruncatableService ) { } @@ -96,6 +103,6 @@ export class ItemListPreviewComponent implements OnInit { ngOnInit(): void { this.showThumbnails = this.showThumbnails ?? this.appConfig.browseBy.showThumbnails; this.dsoTitle = this.dsoNameService.getHitHighlights(this.object, this.item); + this.isCollapsed$ = this.truncateService.isCollapsed(this.item.uuid); } - } diff --git a/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.html b/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.html index 990166ab51a..521e6d5bd1a 100644 --- a/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.html +++ b/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.html @@ -39,9 +39,18 @@ [innerHTML]="firstMetadataValue('dc.date.issued')">) - - ; - + + + + ; + + + + + ; + + + diff --git a/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.spec.ts b/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.spec.ts index 35654240a5d..e44aadb7a5b 100644 --- a/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.spec.ts +++ b/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.spec.ts @@ -186,13 +186,17 @@ const enviromentNoThumbs = { } }; +const truncatableServiceStub = { + isCollapsed: (id: number) => observableOf(true), +}; + describe('ItemSearchResultListElementComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [TranslateModule.forRoot()], declarations: [ItemSearchResultListElementComponent, TruncatePipe, VarDirective], providers: [ - { provide: TruncatableService, useValue: {} }, + { provide: TruncatableService, useValue: truncatableServiceStub }, { provide: DSONameService, useClass: DSONameServiceMock }, { provide: APP_CONFIG, useValue: environmentUseThumbs } ], @@ -247,6 +251,32 @@ describe('ItemSearchResultListElementComponent', () => { }); }); + describe('When the item has authors and isCollapsed is true', () => { + beforeEach(() => { + spyOn(publicationListElementComponent, 'isCollapsed').and.returnValue(observableOf(true)); + publicationListElementComponent.object = mockItemWithMetadata; + fixture.detectChanges(); + }); + + it('should show limitedMetadata', () => { + const authorElements = fixture.debugElement.queryAll(By.css('span.item-list-authors ds-metadata-link-view')); + expect(authorElements.length).toBe(mockItemWithMetadata.indexableObject.limitedMetadata(publicationListElementComponent.authorMetadata, publicationListElementComponent.additionalMetadataLimit).length); + }); + }); + + describe('When the item has authors and isCollapsed is false', () => { + beforeEach(() => { + spyOn(publicationListElementComponent, 'isCollapsed').and.returnValue(observableOf(false)); + publicationListElementComponent.object = mockItemWithMetadata; + fixture.detectChanges(); + }); + + it('should show allMetadata', () => { + const authorElements = fixture.debugElement.queryAll(By.css('span.item-list-authors ds-metadata-link-view')); + expect(authorElements.length).toBe(mockItemWithMetadata.indexableObject.allMetadata(publicationListElementComponent.authorMetadata).length); + }); + }); + describe('When the item has a publisher', () => { beforeEach(() => { publicationListElementComponent.object = mockItemWithMetadata; @@ -375,7 +405,7 @@ describe('ItemSearchResultListElementComponent', () => { imports: [TranslateModule.forRoot()], declarations: [ItemSearchResultListElementComponent, TruncatePipe], providers: [ - {provide: TruncatableService, useValue: {}}, + {provide: TruncatableService, useValue: truncatableServiceStub}, {provide: DSONameService, useClass: DSONameServiceMock}, { provide: APP_CONFIG, useValue: enviromentNoThumbs } ], diff --git a/src/app/shared/object-list/search-result-list-element/search-result-list-element.component.ts b/src/app/shared/object-list/search-result-list-element/search-result-list-element.component.ts index e55ea5b14a5..aeeb0e912b2 100644 --- a/src/app/shared/object-list/search-result-list-element/search-result-list-element.component.ts +++ b/src/app/shared/object-list/search-result-list-element/search-result-list-element.component.ts @@ -11,6 +11,7 @@ import { TruncatableService } from '../../truncatable/truncatable.service'; import { Metadata } from '../../../core/shared/metadata.utils'; import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; import { APP_CONFIG, AppConfig } from '../../../../config/app-config.interface'; +import { environment } from '../../../../environments/environment'; @Component({ selector: 'ds-search-result-list-element', @@ -23,6 +24,11 @@ export class SearchResultListElementComponent, K exten dso: K; dsoTitle: string; + /** + * Limit of additional metadata values to show + */ + additionalMetadataLimit = environment.followAuthorityMetadataValuesLimit; + public constructor(protected truncatableService: TruncatableService, public dsoNameService: DSONameService, @Inject(APP_CONFIG) protected appConfig?: AppConfig) { diff --git a/src/config/app-config.interface.ts b/src/config/app-config.interface.ts index 589999d4746..1488c1c173e 100644 --- a/src/config/app-config.interface.ts +++ b/src/config/app-config.interface.ts @@ -70,6 +70,8 @@ interface AppConfig extends Config { suggestion: SuggestionConfig[]; addToAnyPlugin: AddToAnyPluginConfig; followAuthorityMetadata: FollowAuthorityMetadata[]; + followAuthorityMaxItemLimit: number; + followAuthorityMetadataValuesLimit: number; metricVisualizationConfig: MetricVisualizationConfig[]; attachmentRendering: AttachmentRenderingConfig; advancedAttachmentRendering: AdvancedAttachmentRenderingConfig; diff --git a/src/config/default-app-config.ts b/src/config/default-app-config.ts index 2c8ebabf6b4..99234d781e8 100644 --- a/src/config/default-app-config.ts +++ b/src/config/default-app-config.ts @@ -404,6 +404,12 @@ export class DefaultAppConfig implements AppConfig { } ]; + // The maximum number of item to process when following authority metadata values. + followAuthorityMaxItemLimit = 100; + // The maximum number of metadata values to process for each metadata key + // when following authority metadata values. + followAuthorityMetadataValuesLimit = 5; + // Collection Page Config collection: CollectionPageConfig = { edit: { diff --git a/src/environments/environment.test.ts b/src/environments/environment.test.ts index a47ca41cf83..c7df8a62c44 100644 --- a/src/environments/environment.test.ts +++ b/src/environments/environment.test.ts @@ -276,6 +276,8 @@ export const environment: BuildConfig = { metadata: ['dc.contributor.author'] } ], + followAuthorityMaxItemLimit: 100, + followAuthorityMetadataValuesLimit: 5, item: { edit: { undoTimeout: 10000 // 10 seconds