From 9084a0b7dcda399bc46e6b63c6ec67546fd33926 Mon Sep 17 00:00:00 2001 From: Paurikova2 Date: Thu, 19 Mar 2026 13:34:06 +0100 Subject: [PATCH] Fix community/collection prefix search filter --- ...ized-collection-selector.component.spec.ts | 6 ++ ...uthorized-collection-selector.component.ts | 3 +- .../dso-selector.component.spec.ts | 88 +++++++++++++++++++ .../dso-selector/dso-selector.component.ts | 32 ++++++- 4 files changed, 124 insertions(+), 5 deletions(-) diff --git a/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.spec.ts b/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.spec.ts index b46df8ff36f..754e7f2c37d 100644 --- a/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.spec.ts +++ b/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.spec.ts @@ -21,6 +21,12 @@ describe('AuthorizedCollectionSelectorComponent', () => { let notificationsService: NotificationsService; + function createCollection(id: string, name: string): Collection { + return Object.assign(new Collection(), { id, name }); + } + + const collectionTest = createCollection('col-test', 'test'); + beforeEach(waitForAsync(() => { collection = Object.assign(new Collection(), { id: 'authorized-collection' diff --git a/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.ts b/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.ts index cc1f9822d67..f6dafac0483 100644 --- a/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.ts +++ b/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.ts @@ -75,7 +75,8 @@ export class AuthorizedCollectionSelectorComponent extends DSOSelectorComponent return searchListService$.pipe( getFirstCompletedRemoteData(), map((rd) => Object.assign(new RemoteData(null, null, null, null), rd, { - payload: hasValue(rd.payload) ? buildPaginatedList(rd.payload.pageInfo, rd.payload.page.map((col) => Object.assign(new CollectionSearchResult(), { indexableObject: col }))) : null, + payload: hasValue(rd.payload) ? buildPaginatedList(rd.payload.pageInfo, + rd.payload.page.map((col) => Object.assign(new CollectionSearchResult(), { indexableObject: col }))) : null, })) ); } diff --git a/src/app/shared/dso-selector/dso-selector/dso-selector.component.spec.ts b/src/app/shared/dso-selector/dso-selector/dso-selector.component.spec.ts index e2acd17bc05..6f35040cefe 100644 --- a/src/app/shared/dso-selector/dso-selector/dso-selector.component.spec.ts +++ b/src/app/shared/dso-selector/dso-selector/dso-selector.component.spec.ts @@ -12,6 +12,7 @@ import { hasValue } from '../../empty.util'; import { createPaginatedList } from '../../testing/utils.test'; import { NotificationsService } from '../../notifications/notifications.service'; import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; +import { SearchFilter } from '../../search/models/search-filter.model'; describe('DSOSelectorComponent', () => { let component: DSOSelectorComponent; @@ -158,6 +159,93 @@ describe('DSOSelectorComponent', () => { }); }); +<<<<<<< Updated upstream +======= + describe('query processing', () => { + beforeEach(() => { + spyOn(searchService, 'search').and.callThrough(); + }); + + describe('for COMMUNITY/COLLECTION types', () => { + beforeEach(() => { + component.types = [DSpaceObjectType.COMMUNITY]; + }); + + it('should use a title startsWith filter for community/collection searches', () => { + component.search('test query', 1); + + expect(searchService.search).toHaveBeenCalledWith( + jasmine.objectContaining({ + query: '', + filters: [jasmine.objectContaining({ + key: 'f.dc.title', + values: ['test query'], + operator: 'startsWith' + })] + }), + null, + true + ); + }); + + it('should pass through internal resource ID queries unchanged', () => { + const resourceIdQuery = component.getCurrentDSOQuery(); + component.search(resourceIdQuery, 1); + + expect(searchService.search).toHaveBeenCalledWith( + jasmine.objectContaining({ + query: resourceIdQuery + }), + null, + true + ); + }); + }); + + describe('for ITEM types', () => { + beforeEach(() => { + component.types = [DSpaceObjectType.ITEM]; + }); + + it('should pass through queries unchanged', () => { + component.search('test query', 1); + + expect(searchService.search).toHaveBeenCalledWith( + jasmine.objectContaining({ + query: 'test query' + }), + null, + true + ); + }); + }); + + describe('edge cases', () => { + beforeEach(() => { + component.types = [DSpaceObjectType.COMMUNITY]; + }); + + it('should treat whitespace-only query as empty and apply default sort', () => { + component.sort = new SortOptions('dc.title', SortDirection.ASC); + component.search(' ', 1); + + expect(searchService.search).toHaveBeenCalledWith( + jasmine.objectContaining({ + query: '', + filters: undefined, + sort: jasmine.objectContaining({ + field: 'dc.title', + direction: SortDirection.ASC, + }), + }), + null, + true + ); + }); + }); + }); + +>>>>>>> Stashed changes describe('when search returns an error', () => { beforeEach(() => { spyOn(searchService, 'search').and.returnValue(createFailedRemoteDataObject$()); diff --git a/src/app/shared/dso-selector/dso-selector/dso-selector.component.ts b/src/app/shared/dso-selector/dso-selector/dso-selector.component.ts index 503e4c44129..142ea974d40 100644 --- a/src/app/shared/dso-selector/dso-selector/dso-selector.component.ts +++ b/src/app/shared/dso-selector/dso-selector/dso-selector.component.ts @@ -44,6 +44,7 @@ import { NotificationType } from '../../notifications/models/notification-type'; import { LISTABLE_NOTIFICATION_OBJECT } from '../../object-list/listable-notification-object/listable-notification-object.resource-type'; +import { SearchFilter } from '../../search/models/search-filter.model'; @Component({ selector: 'ds-dso-selector', @@ -227,16 +228,39 @@ export class DSOSelectorComponent implements OnInit, OnDestroy { * @param useCache Whether or not to use the cache */ search(query: string, page: number, useCache: boolean = true): Observable>>> { - // default sort is only used when there is not query - let efectiveSort = query ? null : this.sort; + const rawQuery = query ?? ''; + const trimmedQuery = rawQuery.trim(); + const hasQuery = isNotEmpty(trimmedQuery); + + // default sort is only used when there is no query + let effectiveSort = hasQuery ? null : this.sort; + + // For community/collection searches with a query, use a title startsWith filter + // so the backend handles prefix matching — Angular does not need to know about Solr syntax. + const filters: SearchFilter[] = []; + let processedQuery = trimmedQuery; + if (hasQuery) { + // Bypass filter-based search for internal field queries (e.g. search.resourceid:) + const isInternalFieldQuery = /^\w[\w.]*:/.test(trimmedQuery); + if (!isInternalFieldQuery + && (this.types.includes(DSpaceObjectType.COMMUNITY) || this.types.includes(DSpaceObjectType.COLLECTION))) { + // Use f.dc.title so the backend maps to dc.title_sort via startsWith operator. + // Communities and collections explicitly index dc.title_sort, enabling reliable + // case-insensitive prefix matching without requiring a separate title_sort alias. + filters.push(new SearchFilter('f.dc.title', [trimmedQuery], 'startsWith')); + processedQuery = ''; + } + } + return this.searchService.search( new PaginatedSearchOptions({ - query: query, + query: processedQuery, dsoTypes: this.types, + filters: filters.length > 0 ? filters : undefined, pagination: Object.assign({}, this.defaultPagination, { currentPage: page }), - sort: efectiveSort + sort: effectiveSort }), null, useCache,