From cdd91cd336cd1e5aa7056f38f58fa8843a4dc9d6 Mon Sep 17 00:00:00 2001 From: Paurikova2 Date: Wed, 4 Mar 2026 12:57:37 +0100 Subject: [PATCH 01/14] added searching by query --- .../dso-selector/dso-selector.component.ts | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) 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..7d06da38326 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 @@ -229,9 +229,32 @@ export class DSOSelectorComponent implements OnInit, OnDestroy { 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; + + // Enable partial matching by adding wildcard for any non-empty query + let processedQuery = query; + if (hasValue(query) && query.trim().length > 0) { + const trimmedQuery = query.trim(); + + // For communities and collections, search only at the beginning of titles + if (this.types.includes(DSpaceObjectType.COMMUNITY) || this.types.includes(DSpaceObjectType.COLLECTION)) { + // Use title field with prefix matching to match only at the beginning + // This searches specifically in the title field for words that start with the query + processedQuery = `dc.title:${trimmedQuery}*`; + } else { + // For items and other types, use the query as-is but consider wildcards for very short queries + if (trimmedQuery.length === 1) { + processedQuery = trimmedQuery + '*'; + } else { + processedQuery = trimmedQuery; + } + } + + console.log(`DSO Selector: Searching with query "${processedQuery}" for types:`, this.types); + } + return this.searchService.search( new PaginatedSearchOptions({ - query: query, + query: processedQuery, dsoTypes: this.types, pagination: Object.assign({}, this.defaultPagination, { currentPage: page From 893d47eb77d5208a9e8ee8620a125a200329bdb3 Mon Sep 17 00:00:00 2001 From: Paurikova2 Date: Wed, 4 Mar 2026 13:42:39 +0100 Subject: [PATCH 02/14] removed trailing spaces --- .../shared/dso-selector/dso-selector/dso-selector.component.ts | 2 -- 1 file changed, 2 deletions(-) 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 7d06da38326..d3882a59840 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 @@ -234,7 +234,6 @@ export class DSOSelectorComponent implements OnInit, OnDestroy { let processedQuery = query; if (hasValue(query) && query.trim().length > 0) { const trimmedQuery = query.trim(); - // For communities and collections, search only at the beginning of titles if (this.types.includes(DSpaceObjectType.COMMUNITY) || this.types.includes(DSpaceObjectType.COLLECTION)) { // Use title field with prefix matching to match only at the beginning @@ -248,7 +247,6 @@ export class DSOSelectorComponent implements OnInit, OnDestroy { processedQuery = trimmedQuery; } } - console.log(`DSO Selector: Searching with query "${processedQuery}" for types:`, this.types); } From f97a5d1dd0b75fe1e091b66fe1d0c24bbd31db06 Mon Sep 17 00:00:00 2001 From: Paurikova2 Date: Thu, 5 Mar 2026 07:41:30 +0100 Subject: [PATCH 03/14] Fix dc.title field scoping in search queries --- .../dso-selector.component.spec.ts | 168 ++++++++++++++++++ .../dso-selector/dso-selector.component.ts | 27 ++- 2 files changed, 193 insertions(+), 2 deletions(-) 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..2b700d2600d 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 @@ -158,6 +158,174 @@ describe('DSOSelectorComponent', () => { }); }); + describe('query processing', () => { + beforeEach(() => { + spyOn(searchService, 'search').and.callThrough(); + }); + + describe('for COMMUNITY types', () => { + beforeEach(() => { + component.types = [DSpaceObjectType.COMMUNITY]; + }); + + it('should create title field query with wildcard for single term', () => { + component.search('test', 1); + + expect(searchService.search).toHaveBeenCalledWith( + jasmine.objectContaining({ + query: 'dc.title:test*' + }), + null, + true + ); + }); + + it('should create grouped title field query for multiple terms with wildcard on last term', () => { + component.search('test community name', 1); + + expect(searchService.search).toHaveBeenCalledWith( + jasmine.objectContaining({ + query: 'dc.title:("test" AND "community" AND name*)' + }), + null, + true + ); + }); + + it('should escape special Lucene characters in query terms', () => { + component.search('test+query [with] special:chars', 1); + + expect(searchService.search).toHaveBeenCalledWith( + jasmine.objectContaining({ + query: 'dc.title:("test\\+query" AND "\\[with\\]" AND special\\:chars*)' + }), + null, + true + ); + }); + }); + + describe('for COLLECTION types', () => { + beforeEach(() => { + component.types = [DSpaceObjectType.COLLECTION]; + }); + + it('should create title field query with wildcard for single term', () => { + component.search('documents', 1); + + expect(searchService.search).toHaveBeenCalledWith( + jasmine.objectContaining({ + query: 'dc.title:documents*' + }), + null, + true + ); + }); + + it('should create grouped title field query for multiple terms', () => { + component.search('research papers collection', 1); + + expect(searchService.search).toHaveBeenCalledWith( + jasmine.objectContaining({ + query: 'dc.title:("research" AND "papers" AND collection*)' + }), + null, + true + ); + }); + }); + + describe('for ITEM types', () => { + beforeEach(() => { + component.types = [DSpaceObjectType.ITEM]; + }); + + it('should add wildcard for single character queries', () => { + component.search('a', 1); + + expect(searchService.search).toHaveBeenCalledWith( + jasmine.objectContaining({ + query: 'a*' + }), + null, + true + ); + }); + + it('should pass through multi-character queries unchanged', () => { + component.search('test query', 1); + + expect(searchService.search).toHaveBeenCalledWith( + jasmine.objectContaining({ + query: 'test query' + }), + null, + true + ); + }); + }); + + describe('for mixed types (COMMUNITY and ITEM)', () => { + beforeEach(() => { + component.types = [DSpaceObjectType.COMMUNITY, DSpaceObjectType.ITEM]; + }); + + it('should use title field search when communities are included', () => { + component.search('mixed search', 1); + + expect(searchService.search).toHaveBeenCalledWith( + jasmine.objectContaining({ + query: 'dc.title:("mixed" AND search*)' + }), + null, + true + ); + }); + }); + + describe('edge cases', () => { + beforeEach(() => { + component.types = [DSpaceObjectType.COMMUNITY]; + }); + + it('should handle whitespace-only query', () => { + component.search(' ', 1); + + expect(searchService.search).toHaveBeenCalledWith( + jasmine.objectContaining({ + query: ' ' + }), + null, + true + ); + }); + + it('should handle empty string query', () => { + component.search('', 1); + + expect(searchService.search).toHaveBeenCalledWith( + jasmine.objectContaining({ + query: '' + }), + null, + true + ); + }); + + it('should trim whitespace and handle single term', () => { + component.search(' test ', 1); + + expect(searchService.search).toHaveBeenCalledWith( + jasmine.objectContaining({ + query: 'dc.title:test*' + }), + null, + true + ); + }); + }); + }); + 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 d3882a59840..98141fa02a9 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 @@ -238,7 +238,19 @@ export class DSOSelectorComponent implements OnInit, OnDestroy { if (this.types.includes(DSpaceObjectType.COMMUNITY) || this.types.includes(DSpaceObjectType.COLLECTION)) { // Use title field with prefix matching to match only at the beginning // This searches specifically in the title field for words that start with the query - processedQuery = `dc.title:${trimmedQuery}*`; + // Properly escape and group multi-term queries to ensure all terms are scoped to dc.title + const escapedQuery = this.escapeLuceneSpecialCharacters(trimmedQuery); + const terms = escapedQuery.split(/\s+/).filter(term => term.length > 0); + + if (terms.length === 1) { + // Single term: apply wildcard directly + processedQuery = `dc.title:${terms[0]}*`; + } else { + // Multiple terms: group all terms and apply wildcard only to the last term + const allButLast = terms.slice(0, -1).map(term => `"${term}"`).join(' AND '); + const lastTerm = terms[terms.length - 1]; + processedQuery = `dc.title:(${allButLast} AND ${lastTerm}*)`; + } } else { // For items and other types, use the query as-is but consider wildcards for very short queries if (trimmedQuery.length === 1) { @@ -247,7 +259,6 @@ export class DSOSelectorComponent implements OnInit, OnDestroy { processedQuery = trimmedQuery; } } - console.log(`DSO Selector: Searching with query "${processedQuery}" for types:`, this.types); } return this.searchService.search( @@ -322,6 +333,18 @@ export class DSOSelectorComponent implements OnInit, OnDestroy { } } + /** + * Escapes special Lucene/Solr characters in user input to prevent query syntax errors + * @param query The user input query to escape + * @returns The escaped query string + */ + private escapeLuceneSpecialCharacters(query: string): string { + // Escape special Lucene characters: + - && || ! ( ) { } [ ] ^ " ~ * ? : \ / + return query.replace(/[+\-!(){}[\]^"~*?:\\]/g, '\\$&') + .replace(/&&/g, '\\&&') + .replace(/\|\|/g, '\\||'); + } + getName(listableObject: ListableObject): string { return hasValue((listableObject as SearchResult).indexableObject) ? this.dsoNameService.getName((listableObject as SearchResult).indexableObject) : null; From 8f42e9ec51c8ba0a5d3b9f3458247bad18e45ecc Mon Sep 17 00:00:00 2001 From: Paurikova2 Date: Thu, 5 Mar 2026 07:44:35 +0100 Subject: [PATCH 04/14] Fix typo: efectiveSort -> effectiveSort in dso-selector component --- .../dso-selector/dso-selector/dso-selector.component.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 98141fa02a9..3216d8a5495 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 @@ -228,7 +228,7 @@ export class DSOSelectorComponent implements OnInit, OnDestroy { */ 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; + let effectiveSort = query ? null : this.sort; // Enable partial matching by adding wildcard for any non-empty query let processedQuery = query; @@ -268,7 +268,7 @@ export class DSOSelectorComponent implements OnInit, OnDestroy { pagination: Object.assign({}, this.defaultPagination, { currentPage: page }), - sort: efectiveSort + sort: effectiveSort }), null, useCache, From 31640773aa704102a2c786555c145cb75fdee278 Mon Sep 17 00:00:00 2001 From: Paurikova2 Date: Thu, 5 Mar 2026 08:00:09 +0100 Subject: [PATCH 05/14] fix(dso-selector): query normalization and slash escaping --- .../dso-selector.component.spec.ts | 37 ++++++++++++++++--- .../dso-selector/dso-selector.component.ts | 14 ++++--- 2 files changed, 40 insertions(+), 11 deletions(-) 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 2b700d2600d..911dc321f6e 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 @@ -193,11 +193,11 @@ describe('DSOSelectorComponent', () => { }); it('should escape special Lucene characters in query terms', () => { - component.search('test+query [with] special:chars', 1); + component.search('test+query [with] special:chars/paths', 1); expect(searchService.search).toHaveBeenCalledWith( jasmine.objectContaining({ - query: 'dc.title:("test\\+query" AND "\\[with\\]" AND special\\:chars*)' + query: 'dc.title:("test\\+query" AND "\\[with\\]" AND special\\:chars\\/paths*)' }), null, true @@ -288,12 +288,17 @@ describe('DSOSelectorComponent', () => { component.types = [DSpaceObjectType.COMMUNITY]; }); - it('should handle whitespace-only query', () => { + it('should handle whitespace-only query as empty query', () => { + component.sort = new SortOptions('dc.title', SortDirection.ASC); component.search(' ', 1); expect(searchService.search).toHaveBeenCalledWith( jasmine.objectContaining({ - query: ' ' + query: '', + sort: jasmine.objectContaining({ + field: 'dc.title', + direction: SortDirection.ASC, + }) }), null, true @@ -301,11 +306,33 @@ describe('DSOSelectorComponent', () => { }); it('should handle empty string query', () => { + component.sort = new SortOptions('dc.title', SortDirection.ASC); component.search('', 1); expect(searchService.search).toHaveBeenCalledWith( jasmine.objectContaining({ - query: '' + query: '', + sort: jasmine.objectContaining({ + field: 'dc.title', + direction: SortDirection.ASC, + }) + }), + null, + true + ); + }); + + it('should handle null query', () => { + component.sort = new SortOptions('dc.title', SortDirection.ASC); + component.search(null, 1); + + expect(searchService.search).toHaveBeenCalledWith( + jasmine.objectContaining({ + query: '', + sort: jasmine.objectContaining({ + field: 'dc.title', + direction: SortDirection.ASC, + }) }), null, true 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 3216d8a5495..ecc472e1d20 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 @@ -227,13 +227,15 @@ 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 effectiveSort = query ? null : this.sort; + // Normalize query once and use consistently for both sort selection and query processing + const trimmedQuery = query?.trim() ?? ''; + const hasQuery = isNotEmpty(trimmedQuery); + // default sort is only used when there is no query + let effectiveSort = hasQuery ? null : this.sort; // Enable partial matching by adding wildcard for any non-empty query - let processedQuery = query; - if (hasValue(query) && query.trim().length > 0) { - const trimmedQuery = query.trim(); + let processedQuery = trimmedQuery; + if (hasQuery) { // For communities and collections, search only at the beginning of titles if (this.types.includes(DSpaceObjectType.COMMUNITY) || this.types.includes(DSpaceObjectType.COLLECTION)) { // Use title field with prefix matching to match only at the beginning @@ -340,7 +342,7 @@ export class DSOSelectorComponent implements OnInit, OnDestroy { */ private escapeLuceneSpecialCharacters(query: string): string { // Escape special Lucene characters: + - && || ! ( ) { } [ ] ^ " ~ * ? : \ / - return query.replace(/[+\-!(){}[\]^"~*?:\\]/g, '\\$&') + return query.replace(/[+\-!(){}[\]^"~*?:\\\/]/g, '\\$&') .replace(/&&/g, '\\&&') .replace(/\|\|/g, '\\||'); } From 34e363707b9863cc9a43aa05d44b809590cd3259 Mon Sep 17 00:00:00 2001 From: Paurikova2 Date: Thu, 5 Mar 2026 08:19:42 +0100 Subject: [PATCH 06/14] fix query normalization and tests --- .../dso-selector/dso-selector/dso-selector.component.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 911dc321f6e..69b02877a38 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 @@ -132,7 +132,7 @@ describe('DSOSelectorComponent', () => { expect(searchService.search).toHaveBeenCalledWith( jasmine.objectContaining({ - query: undefined, + query: '', sort: jasmine.objectContaining({ field: 'dc.title', direction: SortDirection.ASC, From 117900b0c4355c377b0cd182c3a23f6363c7172f Mon Sep 17 00:00:00 2001 From: Paurikova2 Date: Thu, 5 Mar 2026 08:29:03 +0100 Subject: [PATCH 07/14] test: add regression tests, fix query processing --- .../dso-selector.component.spec.ts | 39 +++++++++++++++---- .../dso-selector/dso-selector.component.ts | 24 ++++++------ 2 files changed, 42 insertions(+), 21 deletions(-) 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 69b02877a38..340012f1b10 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 @@ -203,6 +203,19 @@ describe('DSOSelectorComponent', () => { 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 // Should not be processed through title field logic + }), + null, + true + ); + }); }); describe('for COLLECTION types', () => { @@ -233,6 +246,19 @@ describe('DSOSelectorComponent', () => { 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 // Should not be processed through title field logic + }), + null, + true + ); + }); }); describe('for ITEM types', () => { @@ -240,12 +266,12 @@ describe('DSOSelectorComponent', () => { component.types = [DSpaceObjectType.ITEM]; }); - it('should add wildcard for single character queries', () => { + it('should pass through single character queries unchanged', () => { component.search('a', 1); expect(searchService.search).toHaveBeenCalledWith( jasmine.objectContaining({ - query: 'a*' + query: 'a' }), null, true @@ -288,17 +314,14 @@ describe('DSOSelectorComponent', () => { component.types = [DSpaceObjectType.COMMUNITY]; }); - it('should handle whitespace-only query as empty query', () => { + it('should handle whitespace-only query with raw semantics', () => { component.sort = new SortOptions('dc.title', SortDirection.ASC); component.search(' ', 1); expect(searchService.search).toHaveBeenCalledWith( jasmine.objectContaining({ - query: '', - sort: jasmine.objectContaining({ - field: 'dc.title', - direction: SortDirection.ASC, - }) + query: ' ', + sort: null // Raw query semantics: whitespace is considered a query, so no default sort }), null, true 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 ecc472e1d20..303d654a039 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 @@ -227,15 +227,17 @@ export class DSOSelectorComponent implements OnInit, OnDestroy { * @param useCache Whether or not to use the cache */ search(query: string, page: number, useCache: boolean = true): Observable>>> { - // Normalize query once and use consistently for both sort selection and query processing - const trimmedQuery = query?.trim() ?? ''; - const hasQuery = isNotEmpty(trimmedQuery); - // default sort is only used when there is no query + // Keep raw query semantics aligned with other component logic (isEmpty/isNotEmpty checks) + const rawQuery = query ?? ''; + const trimmedQuery = rawQuery.trim(); + const hasQuery = isNotEmpty(rawQuery); + + // default sort is only used when there is no query according to raw input semantics let effectiveSort = hasQuery ? null : this.sort; - // Enable partial matching by adding wildcard for any non-empty query - let processedQuery = trimmedQuery; - if (hasQuery) { + // Enable partial matching by adding wildcard for any trimmed non-empty query + let processedQuery = rawQuery; + if (isNotEmpty(trimmedQuery)) { // For communities and collections, search only at the beginning of titles if (this.types.includes(DSpaceObjectType.COMMUNITY) || this.types.includes(DSpaceObjectType.COLLECTION)) { // Use title field with prefix matching to match only at the beginning @@ -254,12 +256,8 @@ export class DSOSelectorComponent implements OnInit, OnDestroy { processedQuery = `dc.title:(${allButLast} AND ${lastTerm}*)`; } } else { - // For items and other types, use the query as-is but consider wildcards for very short queries - if (trimmedQuery.length === 1) { - processedQuery = trimmedQuery + '*'; - } else { - processedQuery = trimmedQuery; - } + // For items and other types, use the trimmed query as-is without wildcard modification + processedQuery = trimmedQuery; } } From 4646b3f68333d7c4db515a062c02789b419e447f Mon Sep 17 00:00:00 2001 From: Paurikova2 Date: Thu, 5 Mar 2026 08:47:20 +0100 Subject: [PATCH 08/14] refactor(tests): consolidate redundant query processing test cases --- .../dso-selector.component.spec.ts | 153 +----------------- .../dso-selector/dso-selector.component.ts | 5 - 2 files changed, 5 insertions(+), 153 deletions(-) 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 340012f1b10..68262fac386 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 @@ -163,36 +163,12 @@ describe('DSOSelectorComponent', () => { spyOn(searchService, 'search').and.callThrough(); }); - describe('for COMMUNITY types', () => { + describe('for COMMUNITY/COLLECTION types', () => { beforeEach(() => { component.types = [DSpaceObjectType.COMMUNITY]; }); - it('should create title field query with wildcard for single term', () => { - component.search('test', 1); - - expect(searchService.search).toHaveBeenCalledWith( - jasmine.objectContaining({ - query: 'dc.title:test*' - }), - null, - true - ); - }); - - it('should create grouped title field query for multiple terms with wildcard on last term', () => { - component.search('test community name', 1); - - expect(searchService.search).toHaveBeenCalledWith( - jasmine.objectContaining({ - query: 'dc.title:("test" AND "community" AND name*)' - }), - null, - true - ); - }); - - it('should escape special Lucene characters in query terms', () => { + it('should create title field query with escaping and wildcards', () => { component.search('test+query [with] special:chars/paths', 1); expect(searchService.search).toHaveBeenCalledWith( @@ -210,50 +186,7 @@ describe('DSOSelectorComponent', () => { expect(searchService.search).toHaveBeenCalledWith( jasmine.objectContaining({ - query: resourceIdQuery // Should not be processed through title field logic - }), - null, - true - ); - }); - }); - - describe('for COLLECTION types', () => { - beforeEach(() => { - component.types = [DSpaceObjectType.COLLECTION]; - }); - - it('should create title field query with wildcard for single term', () => { - component.search('documents', 1); - - expect(searchService.search).toHaveBeenCalledWith( - jasmine.objectContaining({ - query: 'dc.title:documents*' - }), - null, - true - ); - }); - - it('should create grouped title field query for multiple terms', () => { - component.search('research papers collection', 1); - - expect(searchService.search).toHaveBeenCalledWith( - jasmine.objectContaining({ - query: 'dc.title:("research" AND "papers" AND collection*)' - }), - 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 // Should not be processed through title field logic + query: resourceIdQuery }), null, true @@ -266,19 +199,7 @@ describe('DSOSelectorComponent', () => { component.types = [DSpaceObjectType.ITEM]; }); - it('should pass through single character queries unchanged', () => { - component.search('a', 1); - - expect(searchService.search).toHaveBeenCalledWith( - jasmine.objectContaining({ - query: 'a' - }), - null, - true - ); - }); - - it('should pass through multi-character queries unchanged', () => { + it('should pass through queries unchanged', () => { component.search('test query', 1); expect(searchService.search).toHaveBeenCalledWith( @@ -291,24 +212,6 @@ describe('DSOSelectorComponent', () => { }); }); - describe('for mixed types (COMMUNITY and ITEM)', () => { - beforeEach(() => { - component.types = [DSpaceObjectType.COMMUNITY, DSpaceObjectType.ITEM]; - }); - - it('should use title field search when communities are included', () => { - component.search('mixed search', 1); - - expect(searchService.search).toHaveBeenCalledWith( - jasmine.objectContaining({ - query: 'dc.title:("mixed" AND search*)' - }), - null, - true - ); - }); - }); - describe('edge cases', () => { beforeEach(() => { component.types = [DSpaceObjectType.COMMUNITY]; @@ -321,53 +224,7 @@ describe('DSOSelectorComponent', () => { expect(searchService.search).toHaveBeenCalledWith( jasmine.objectContaining({ query: ' ', - sort: null // Raw query semantics: whitespace is considered a query, so no default sort - }), - null, - true - ); - }); - - it('should handle empty string query', () => { - component.sort = new SortOptions('dc.title', SortDirection.ASC); - component.search('', 1); - - expect(searchService.search).toHaveBeenCalledWith( - jasmine.objectContaining({ - query: '', - sort: jasmine.objectContaining({ - field: 'dc.title', - direction: SortDirection.ASC, - }) - }), - null, - true - ); - }); - - it('should handle null query', () => { - component.sort = new SortOptions('dc.title', SortDirection.ASC); - component.search(null, 1); - - expect(searchService.search).toHaveBeenCalledWith( - jasmine.objectContaining({ - query: '', - sort: jasmine.objectContaining({ - field: 'dc.title', - direction: SortDirection.ASC, - }) - }), - null, - true - ); - }); - - it('should trim whitespace and handle single term', () => { - component.search(' test ', 1); - - expect(searchService.search).toHaveBeenCalledWith( - jasmine.objectContaining({ - query: 'dc.title:test*' + sort: null }), null, true 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 303d654a039..342a1f1c5d3 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 @@ -227,20 +227,15 @@ export class DSOSelectorComponent implements OnInit, OnDestroy { * @param useCache Whether or not to use the cache */ search(query: string, page: number, useCache: boolean = true): Observable>>> { - // Keep raw query semantics aligned with other component logic (isEmpty/isNotEmpty checks) const rawQuery = query ?? ''; const trimmedQuery = rawQuery.trim(); const hasQuery = isNotEmpty(rawQuery); - // default sort is only used when there is no query according to raw input semantics let effectiveSort = hasQuery ? null : this.sort; - // Enable partial matching by adding wildcard for any trimmed non-empty query let processedQuery = rawQuery; if (isNotEmpty(trimmedQuery)) { - // For communities and collections, search only at the beginning of titles if (this.types.includes(DSpaceObjectType.COMMUNITY) || this.types.includes(DSpaceObjectType.COLLECTION)) { - // Use title field with prefix matching to match only at the beginning // This searches specifically in the title field for words that start with the query // Properly escape and group multi-term queries to ensure all terms are scoped to dc.title const escapedQuery = this.escapeLuceneSpecialCharacters(trimmedQuery); From b12e7e8c260f341ccf6003182aeffdd0356b7c21 Mon Sep 17 00:00:00 2001 From: Paurikova2 Date: Thu, 5 Mar 2026 09:24:19 +0100 Subject: [PATCH 09/14] Bypass query rewriting for internal Solr field queries --- .../dso-selector/dso-selector/dso-selector.component.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 3216d8a5495..df80ccac3c4 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 @@ -234,8 +234,13 @@ export class DSOSelectorComponent implements OnInit, OnDestroy { let processedQuery = query; if (hasValue(query) && query.trim().length > 0) { const trimmedQuery = query.trim(); + // Bypass query rewriting for internal Solr field queries (e.g. search.resourceid:) + // so that getCurrentDSOQuery() results are passed through unchanged. + const isInternalSolrQuery = /^\w[\w.]*:/.test(trimmedQuery); + if (isInternalSolrQuery) { + processedQuery = trimmedQuery; // For communities and collections, search only at the beginning of titles - if (this.types.includes(DSpaceObjectType.COMMUNITY) || this.types.includes(DSpaceObjectType.COLLECTION)) { + } else if (this.types.includes(DSpaceObjectType.COMMUNITY) || this.types.includes(DSpaceObjectType.COLLECTION)) { // Use title field with prefix matching to match only at the beginning // This searches specifically in the title field for words that start with the query // Properly escape and group multi-term queries to ensure all terms are scoped to dc.title From 841b7f304020b791862f0b932a7e45f83da42a9e Mon Sep 17 00:00:00 2001 From: Paurikova2 Date: Thu, 5 Mar 2026 10:37:11 +0100 Subject: [PATCH 10/14] added partial word match also for new item browsing collection --- ...uthorized-collection-selector.component.ts | 26 ++++++++++-- .../dso-selector.component.spec.ts | 18 +++++++++ .../dso-selector/dso-selector.component.ts | 40 ++++++++++++------- 3 files changed, 65 insertions(+), 19 deletions(-) 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..f3e362d8706 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 @@ -11,7 +11,7 @@ import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; import { buildPaginatedList, PaginatedList } from '../../../../core/data/paginated-list.model'; import { followLink } from '../../../utils/follow-link-config.model'; import { RemoteData } from '../../../../core/data/remote-data'; -import { hasValue } from '../../../empty.util'; +import { hasValue, isNotEmpty } from '../../../empty.util'; import { NotificationsService } from '../../../notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; import { Collection } from '../../../../core/shared/collection.model'; @@ -74,9 +74,27 @@ 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, - })) + map((rd) => { + if (!hasValue(rd.payload)) { + return Object.assign(new RemoteData(null, null, null, null), rd, { payload: null }); + } + let searchResults = rd.payload.page.map((col) => + Object.assign(new CollectionSearchResult(), { indexableObject: col }) + ); + // The findSubmitAuthorized endpoint does full-text search across all fields, + // which returns false positives. Apply client-side title prefix filtering + // to match the same behavior as community/collection creation selectors. + if (isNotEmpty(query)) { + const lowerQuery = query.trim().toLowerCase(); + searchResults = searchResults.filter((result) => { + const name = this.dsoNameService.getName(result.indexableObject); + return hasValue(name) && name.toLowerCase().startsWith(lowerQuery); + }); + } + return Object.assign(new RemoteData(null, null, null, null), rd, { + payload: buildPaginatedList(rd.payload.pageInfo, searchResults), + }); + }) ); } } 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 2b700d2600d..71ed0b43bb8 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 @@ -235,6 +235,24 @@ describe('DSOSelectorComponent', () => { }); }); + describe('for COMMUNITY/COLLECTION types', () => { + beforeEach(() => { + component.types = [DSpaceObjectType.COMMUNITY]; + }); + + it('should pass through internal resource ID queries unchanged', () => { + component.search('search.resourceid:test-uuid-ford-sose', 1); + + expect(searchService.search).toHaveBeenCalledWith( + jasmine.objectContaining({ + query: 'search.resourceid:test-uuid-ford-sose' + }), + null, + true + ); + }); + }); + describe('for ITEM types', () => { beforeEach(() => { component.types = [DSpaceObjectType.ITEM]; 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 df80ccac3c4..15af0fe0938 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 @@ -241,21 +241,7 @@ export class DSOSelectorComponent implements OnInit, OnDestroy { processedQuery = trimmedQuery; // For communities and collections, search only at the beginning of titles } else if (this.types.includes(DSpaceObjectType.COMMUNITY) || this.types.includes(DSpaceObjectType.COLLECTION)) { - // Use title field with prefix matching to match only at the beginning - // This searches specifically in the title field for words that start with the query - // Properly escape and group multi-term queries to ensure all terms are scoped to dc.title - const escapedQuery = this.escapeLuceneSpecialCharacters(trimmedQuery); - const terms = escapedQuery.split(/\s+/).filter(term => term.length > 0); - - if (terms.length === 1) { - // Single term: apply wildcard directly - processedQuery = `dc.title:${terms[0]}*`; - } else { - // Multiple terms: group all terms and apply wildcard only to the last term - const allButLast = terms.slice(0, -1).map(term => `"${term}"`).join(' AND '); - const lastTerm = terms[terms.length - 1]; - processedQuery = `dc.title:(${allButLast} AND ${lastTerm}*)`; - } + processedQuery = this.buildTitlePrefixQuery(trimmedQuery); } else { // For items and other types, use the query as-is but consider wildcards for very short queries if (trimmedQuery.length === 1) { @@ -338,6 +324,30 @@ export class DSOSelectorComponent implements OnInit, OnDestroy { } } + /** + * Builds a dc.title partial matching query with wildcard support. + * Single term: dc.title:term* + * Multiple terms: dc.title:("term1" AND term2*) + * @param query The raw user input query + * @returns The processed query string with dc.title prefix matching, or the original query if empty + */ + protected buildTitlePrefixQuery(query: string): string { + if (hasValue(query) && query.trim().length > 0) { + const trimmedQuery = query.trim(); + const escapedQuery = this.escapeLuceneSpecialCharacters(trimmedQuery); + const terms = escapedQuery.split(/\s+/).filter(term => term.length > 0); + + if (terms.length === 1) { + return `dc.title:${terms[0]}*`; + } else { + const allButLast = terms.slice(0, -1).map(term => `"${term}"`).join(' AND '); + const lastTerm = terms[terms.length - 1]; + return `dc.title:(${allButLast} AND ${lastTerm}*)`; + } + } + return query; + } + /** * Escapes special Lucene/Solr characters in user input to prevent query syntax errors * @param query The user input query to escape From 129c3ffee07bd21a641d9ee036194df76bbfecb5 Mon Sep 17 00:00:00 2001 From: Paurikova2 Date: Thu, 5 Mar 2026 10:46:39 +0100 Subject: [PATCH 11/14] Add partial matching to item creation --- .../dso-selector/dso-selector.component.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 07965ff3535..006173b0750 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 @@ -231,12 +231,12 @@ export class DSOSelectorComponent implements OnInit, OnDestroy { const trimmedQuery = rawQuery.trim(); const hasQuery = isNotEmpty(rawQuery); - // Enable partial matching by adding wildcard for any non-empty query - let processedQuery = query; - if (hasValue(query) && query.trim().length > 0) { - const trimmedQuery = query.trim(); + // default sort is only used when there is no query + let effectiveSort = hasQuery ? null : this.sort; + + let processedQuery = rawQuery; + if (isNotEmpty(trimmedQuery)) { // Bypass query rewriting for internal Solr field queries (e.g. search.resourceid:) - // so that getCurrentDSOQuery() results are passed through unchanged. const isInternalSolrQuery = /^\w[\w.]*:/.test(trimmedQuery); if (isInternalSolrQuery) { processedQuery = trimmedQuery; From 5b00eda7c765ff97f3de29c3a61f677ed271901d Mon Sep 17 00:00:00 2001 From: Paurikova2 Date: Thu, 5 Mar 2026 11:35:11 +0100 Subject: [PATCH 12/14] fix(dso-selector): derive hasNextPage from currentPage < totalPages --- ...ized-collection-selector.component.spec.ts | 84 +++++++++++++++---- ...uthorized-collection-selector.component.ts | 34 +++++++- .../dso-selector.component.spec.ts | 27 ++---- .../dso-selector/dso-selector.component.ts | 10 ++- 4 files changed, 110 insertions(+), 45 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..134dc58d8a3 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 @@ -11,25 +11,40 @@ import { createPaginatedList } from '../../../testing/utils.test'; import { Collection } from '../../../../core/shared/collection.model'; import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model'; import { NotificationsService } from '../../../notifications/notifications.service'; +import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; describe('AuthorizedCollectionSelectorComponent', () => { let component: AuthorizedCollectionSelectorComponent; let fixture: ComponentFixture; let collectionService; - let collection; - + let dsoNameService: jasmine.SpyObj; let notificationsService: NotificationsService; + function createCollection(id: string, name: string): Collection { + return Object.assign(new Collection(), { id, name }); + } + + const collectionTest = createCollection('col-test', 'test'); + const collectionTestSuite = createCollection('col-suite', 'test suite'); + const collectionCollection = createCollection('col-collection', 'collection'); + beforeEach(waitForAsync(() => { - collection = Object.assign(new Collection(), { - id: 'authorized-collection' - }); - collectionService = jasmine.createSpyObj('collectionService', { - getAuthorizedCollection: createSuccessfulRemoteDataObject$(createPaginatedList([collection])), - getAuthorizedCollectionByEntityType: createSuccessfulRemoteDataObject$(createPaginatedList([collection])) - }); + dsoNameService = jasmine.createSpyObj('dsoNameService', ['getName']); + dsoNameService.getName.and.callFake((dso: any) => dso?.name ?? ''); + notificationsService = jasmine.createSpyObj('notificationsService', ['error']); + + // Use callFake so createSuccessfulRemoteDataObject$ is called lazily at spy invocation time + // (not at setup time), avoiding issues with environment not being available during beforeEach. + collectionService = jasmine.createSpyObj('collectionService', ['getAuthorizedCollection', 'getAuthorizedCollectionByEntityType']); + collectionService.getAuthorizedCollection.and.callFake(() => + createSuccessfulRemoteDataObject$(createPaginatedList([collectionTest])) + ); + collectionService.getAuthorizedCollectionByEntityType.and.callFake(() => + createSuccessfulRemoteDataObject$(createPaginatedList([collectionTest])) + ); + TestBed.configureTestingModule({ declarations: [AuthorizedCollectionSelectorComponent, VarDirective], imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], @@ -37,6 +52,7 @@ describe('AuthorizedCollectionSelectorComponent', () => { { provide: SearchService, useValue: {} }, { provide: CollectionDataService, useValue: collectionService }, { provide: NotificationsService, useValue: notificationsService }, + { provide: DSONameService, useValue: dsoNameService }, ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); @@ -51,24 +67,60 @@ describe('AuthorizedCollectionSelectorComponent', () => { describe('search', () => { describe('when has no entity type', () => { - it('should call getAuthorizedCollection and return the authorized collection in a SearchResult', (done) => { - component.search('', 1).subscribe((resultRD) => { + it('should call getAuthorizedCollection and return the collection wrapped in a SearchResult', (done) => { + component.search('', 1).subscribe((resultRD) => { expect(collectionService.getAuthorizedCollection).toHaveBeenCalled(); - expect(resultRD.payload.page.length).toEqual(1); - expect(resultRD.payload.page[0].indexableObject).toEqual(collection); + expect(resultRD.payload.page.length).toEqual(1); + expect(resultRD.payload.page[0].indexableObject).toEqual(collectionTest); done(); }); }); }); describe('when has entity type', () => { - it('should call getAuthorizedCollectionByEntityType and return the authorized collection in a SearchResult', (done) => { - component.entityType = 'test'; + it('should call getAuthorizedCollectionByEntityType and return the collection wrapped in a SearchResult', (done) => { + component.entityType = 'Publication'; fixture.detectChanges(); component.search('', 1).subscribe((resultRD) => { expect(collectionService.getAuthorizedCollectionByEntityType).toHaveBeenCalled(); expect(resultRD.payload.page.length).toEqual(1); - expect(resultRD.payload.page[0].indexableObject).toEqual(collection); + expect(resultRD.payload.page[0].indexableObject).toEqual(collectionTest); + done(); + }); + }); + }); + + describe('title prefix filtering', () => { + beforeEach(() => { + // Override to return all three collections so we can test client-side filtering + collectionService.getAuthorizedCollection.and.callFake(() => + createSuccessfulRemoteDataObject$( + createPaginatedList([collectionTest, collectionTestSuite, collectionCollection]) + ) + ); + }); + + it('should return all collections when query is empty', (done) => { + component.search('', 1).subscribe((resultRD) => { + expect(resultRD.payload.page.length).toEqual(3); + done(); + }); + }); + + it('should return only collections whose title starts with the query', (done) => { + component.search('test', 1).subscribe((resultRD) => { + const names = resultRD.payload.page.map((r: any) => r.indexableObject.name); + expect(names).toEqual(['test', 'test suite']); + expect(names).not.toContain('collection'); + done(); + }); + }); + + it('should be case-insensitive', (done) => { + component.search('TEST', 1).subscribe((resultRD) => { + const names = resultRD.payload.page.map((r: any) => r.indexableObject.name); + expect(names).toContain('test'); + expect(names).toContain('test suite'); done(); }); }); 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 f3e362d8706..abae371e70a 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 @@ -11,12 +11,15 @@ import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; import { buildPaginatedList, PaginatedList } from '../../../../core/data/paginated-list.model'; import { followLink } from '../../../utils/follow-link-config.model'; import { RemoteData } from '../../../../core/data/remote-data'; -import { hasValue, isNotEmpty } from '../../../empty.util'; +import { hasNoValue, hasValue, isNotEmpty } from '../../../empty.util'; import { NotificationsService } from '../../../notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; import { Collection } from '../../../../core/shared/collection.model'; import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; import { FindListOptions } from '../../../../core/data/find-list-options.model'; +import { NotificationType } from '../../../notifications/models/notification-type'; +import { ListableNotificationObject } from '../../../object-list/listable-notification-object/listable-notification-object.model'; +import { LISTABLE_NOTIFICATION_OBJECT } from '../../../object-list/listable-notification-object/listable-notification-object.resource-type'; @Component({ selector: 'ds-authorized-collection-selector', @@ -81,9 +84,6 @@ export class AuthorizedCollectionSelectorComponent extends DSOSelectorComponent let searchResults = rd.payload.page.map((col) => Object.assign(new CollectionSearchResult(), { indexableObject: col }) ); - // The findSubmitAuthorized endpoint does full-text search across all fields, - // which returns false positives. Apply client-side title prefix filtering - // to match the same behavior as community/collection creation selectors. if (isNotEmpty(query)) { const lowerQuery = query.trim().toLowerCase(); searchResults = searchResults.filter((result) => { @@ -97,4 +97,30 @@ export class AuthorizedCollectionSelectorComponent extends DSOSelectorComponent }) ); } + + /** + * Override updateList to derive hasNextPage from page-based pagination + * (currentPage < totalPages) instead of totalElements, because client-side + * filtering makes totalElements unreliable for next-page detection. + */ + updateList(rd: RemoteData>>) { + this.loading = false; + const currentEntries = this.listEntries$.getValue(); + if (rd.hasSucceeded) { + if (hasNoValue(currentEntries)) { + this.listEntries$.next(rd.payload.page); + } else { + this.listEntries$.next([...currentEntries, ...rd.payload.page]); + } + // Use page-based check: currentPage is 0-based, totalPages is 1-based + const pageInfo = rd.payload.pageInfo; + this.hasNextPage = hasValue(pageInfo) && pageInfo.currentPage < (pageInfo.totalPages - 1); + } else { + this.listEntries$.next([ + ...(hasNoValue(currentEntries) ? [] : this.listEntries$.getValue()), + new ListableNotificationObject(NotificationType.Error, 'dso-selector.results-could-not-be-retrieved', LISTABLE_NOTIFICATION_OBJECT.value) + ]); + this.hasNextPage = false; + } + } } 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 572befb4064..be1f0e6c9bd 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 @@ -194,24 +194,6 @@ describe('DSOSelectorComponent', () => { }); }); - describe('for COMMUNITY/COLLECTION types', () => { - beforeEach(() => { - component.types = [DSpaceObjectType.COMMUNITY]; - }); - - it('should pass through internal resource ID queries unchanged', () => { - component.search('search.resourceid:test-uuid-ford-sose', 1); - - expect(searchService.search).toHaveBeenCalledWith( - jasmine.objectContaining({ - query: 'search.resourceid:test-uuid-ford-sose' - }), - null, - true - ); - }); - }); - describe('for ITEM types', () => { beforeEach(() => { component.types = [DSpaceObjectType.ITEM]; @@ -235,14 +217,17 @@ describe('DSOSelectorComponent', () => { component.types = [DSpaceObjectType.COMMUNITY]; }); - it('should handle whitespace-only query with raw semantics', () => { + 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: ' ', - sort: null + query: '', + sort: jasmine.objectContaining({ + field: 'dc.title', + direction: SortDirection.ASC, + }), }), null, true 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 006173b0750..48c9798f996 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 @@ -205,8 +205,10 @@ export class DSOSelectorComponent implements OnInit, OnDestroy { } else { this.listEntries$.next([...currentEntries, ...rd.payload.page]); } - // Check if there are more pages available after the current one - this.hasNextPage = rd.payload.totalElements > this.listEntries$.getValue().length; + // Check if the server reports a next page, using page-based comparison so that + // client-side filtering (which reduces list length without changing totalPages) + // does not cause repeated fetching past the server's last page. + this.hasNextPage = rd.payload.currentPage < rd.payload.totalPages; } else { this.listEntries$.next([...(hasNoValue(currentEntries) ? [] : this.listEntries$.getValue()), new ListableNotificationObject(NotificationType.Error, 'dso-selector.results-could-not-be-retrieved', LISTABLE_NOTIFICATION_OBJECT.value)]); this.hasNextPage = false; @@ -229,12 +231,12 @@ export class DSOSelectorComponent implements OnInit, OnDestroy { search(query: string, page: number, useCache: boolean = true): Observable>>> { const rawQuery = query ?? ''; const trimmedQuery = rawQuery.trim(); - const hasQuery = isNotEmpty(rawQuery); + const hasQuery = isNotEmpty(trimmedQuery); // default sort is only used when there is no query let effectiveSort = hasQuery ? null : this.sort; - let processedQuery = rawQuery; + let processedQuery = trimmedQuery; if (isNotEmpty(trimmedQuery)) { // Bypass query rewriting for internal Solr field queries (e.g. search.resourceid:) const isInternalSolrQuery = /^\w[\w.]*:/.test(trimmedQuery); From a3be3701e8147de1262d548b92b1e285163a5963 Mon Sep 17 00:00:00 2001 From: Paurikova2 Date: Thu, 5 Mar 2026 12:52:04 +0100 Subject: [PATCH 13/14] removed unwanted comments --- .../shared/dso-selector/dso-selector/dso-selector.component.ts | 2 -- 1 file changed, 2 deletions(-) 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 48c9798f996..f16818e80ff 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 @@ -242,11 +242,9 @@ export class DSOSelectorComponent implements OnInit, OnDestroy { const isInternalSolrQuery = /^\w[\w.]*:/.test(trimmedQuery); if (isInternalSolrQuery) { processedQuery = trimmedQuery; - // For communities and collections, search only at the beginning of titles } else if (this.types.includes(DSpaceObjectType.COMMUNITY) || this.types.includes(DSpaceObjectType.COLLECTION)) { processedQuery = this.buildTitlePrefixQuery(trimmedQuery); } else { - // For items and other types, use the trimmed query as-is without wildcard modification processedQuery = trimmedQuery; } } From 15e902e2cd49aed31b5d8c865281864ffe10acf1 Mon Sep 17 00:00:00 2001 From: Paurikova2 Date: Wed, 11 Mar 2026 06:57:14 +0100 Subject: [PATCH 14/14] created constraint from dc.title --- .../dso-selector/dso-selector/dso-selector.component.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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 f16818e80ff..ab5548d9bb4 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 @@ -120,6 +120,11 @@ export class DSOSelectorComponent implements OnInit, OnDestroy { */ @ViewChildren('listEntryElement') listElements: QueryList; + /** + * The Solr/Lucene field used for title-based prefix queries + */ + protected readonly TITLE_FIELD = 'dc.title'; + /** * Time to wait before sending a search request to the server when a user types something */ @@ -335,11 +340,11 @@ export class DSOSelectorComponent implements OnInit, OnDestroy { const terms = escapedQuery.split(/\s+/).filter(term => term.length > 0); if (terms.length === 1) { - return `dc.title:${terms[0]}*`; + return `${this.TITLE_FIELD}:${terms[0]}*`; } else { const allButLast = terms.slice(0, -1).map(term => `"${term}"`).join(' AND '); const lastTerm = terms[terms.length - 1]; - return `dc.title:(${allButLast} AND ${lastTerm}*)`; + return `${this.TITLE_FIELD}:(${allButLast} AND ${lastTerm}*)`; } } return query;