From 25f4c11d42c24470eb56cf188f2d16cc04f90199 Mon Sep 17 00:00:00 2001 From: Niels Lindenthal Date: Thu, 3 Sep 2026 13:28:45 +0200 Subject: [PATCH 1/6] Add meetings and wiki pages to global search Expose permission-aware API v3 search filters for meetings and wiki pages, add the missing wiki page collection, and show all three resource types in the global search dropdown. --- config/locales/js-en.yml | 18 +- .../schemas/wiki_page_collection_model.yml | 27 + .../components/schemas/wiki_page_model.yml | 5 + docs/api/apiv3/openapi-spec.yml | 4 + docs/api/apiv3/paths/meetings.yml | 24 + docs/api/apiv3/paths/wiki_pages.yml | 41 ++ docs/api/apiv3/tags/wiki_pages.yml | 3 +- frontend/src/app/core/apiv3/api-v3.service.ts | 3 + .../api-v3-work-packages-paths.ts | 9 +- .../input/global-search-input.component.html | 149 +++++- .../input/global-search-input.component.sass | 118 ++++- .../global-search-input.component.spec.ts | 124 ++++- .../input/global-search-input.component.ts | 464 +++++++++++++++--- .../openproject-global-search.module.ts | 2 + .../services/global-search.service.ts | 13 +- .../hal/resources/meeting-resource.ts | 4 +- .../hal/resources/wiki-page-resource.ts | 11 + .../op-autocompleter.component.ts | 4 + .../components/primer/dynamic-icon-map.ts | 14 +- .../helpers/api-v3/api-v3-filter-builder.ts | 2 +- lib/api/v3/utilities/endpoints/index.rb | 7 +- lib/api/v3/utilities/path_helper.rb | 6 + .../wiki_page_collection_representer.rb | 37 ++ .../v3/wiki_pages/wiki_page_representer.rb | 13 + lib/api/v3/wiki_pages/wiki_pages_api.rb | 9 + .../meeting/app/models/queries/meetings.rb | 2 + .../queries/meetings/filters/search_filter.rb | 68 +++ .../meetings/filters/updated_at_filter.rb | 15 + .../filters/updated_at_filter_spec.rb | 34 ++ .../api/v3/meetings/meetings_resource_spec.rb | 40 ++ .../wikis/wiki_pages/filter/search_filter.rb | 61 +++ .../wiki_pages/filter/updated_at_filter.rb | 27 + .../wikis/lib/open_project/wikis/engine.rb | 1 + .../filter/updated_at_filter_spec.rb | 35 ++ .../api/v3/wiki_pages_resource_spec.rb | 28 ++ 35 files changed, 1299 insertions(+), 123 deletions(-) create mode 100644 docs/api/apiv3/components/schemas/wiki_page_collection_model.yml create mode 100644 docs/api/apiv3/paths/wiki_pages.yml create mode 100644 lib/api/v3/wiki_pages/wiki_page_collection_representer.rb create mode 100644 modules/meeting/app/models/queries/meetings/filters/search_filter.rb create mode 100644 modules/meeting/app/models/queries/meetings/filters/updated_at_filter.rb create mode 100644 modules/meeting/spec/models/queries/meetings/filters/updated_at_filter_spec.rb create mode 100644 modules/wikis/app/models/queries/wikis/wiki_pages/filter/search_filter.rb create mode 100644 modules/wikis/app/models/queries/wikis/wiki_pages/filter/updated_at_filter.rb create mode 100644 modules/wikis/spec/models/queries/wikis/wiki_pages/filter/updated_at_filter_spec.rb diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml index a031761e4357..e1719a5885fb 100644 --- a/config/locales/js-en.yml +++ b/config/locales/js-en.yml @@ -377,12 +377,28 @@ en: current_project_and_all_descendants: "In this project + subprojects" direct_hit_available: "Work package with exact ID found. Press Enter to open it." items_available: "%{count} items available" + quick_filters: + accountable: "Accountable" + any_time: "Any time" + attended: "Attended" + closed: "Closed" + invited: "Invited" + involvement: "Involvement" + meeting_date: "Meeting date" + open: "Open" + past: "Past" + past_30_days: "Past 30 days" + past_7_days: "Past 7 days" + past_year: "Past year" + upcoming: "Upcoming" + yesterday: "Yesterday" recently_viewed: "Recently viewed" - search_placeholder_expanded: "Search work packages by subject, project, type, status or ID" + search_placeholder_expanded: "Search work packages, meetings and wiki pages" title: all_projects: "all projects" project_and_subprojects: "and all subprojects" search_for: "Search for" + wiki_pages: "Wiki pages" hal: error: diff --git a/docs/api/apiv3/components/schemas/wiki_page_collection_model.yml b/docs/api/apiv3/components/schemas/wiki_page_collection_model.yml new file mode 100644 index 000000000000..64fd6faf1d14 --- /dev/null +++ b/docs/api/apiv3/components/schemas/wiki_page_collection_model.yml @@ -0,0 +1,27 @@ +# Schema: WikiPageCollectionModel +--- +allOf: + - $ref: './paginated_collection_model.yml' + - type: object + required: + - _embedded + - _links + properties: + _embedded: + type: object + required: + - elements + properties: + elements: + type: array + items: + $ref: './wiki_page_model.yml' + _links: + type: object + required: + - self + properties: + self: + allOf: + - $ref: './link.yml' + - description: The link to this wiki page collection resource. diff --git a/docs/api/apiv3/components/schemas/wiki_page_model.yml b/docs/api/apiv3/components/schemas/wiki_page_model.yml index 77b40fe5cdf4..d294f1e0794e 100644 --- a/docs/api/apiv3/components/schemas/wiki_page_model.yml +++ b/docs/api/apiv3/components/schemas/wiki_page_model.yml @@ -15,6 +15,11 @@ properties: _links: type: object properties: + showWikiPage: + allOf: + - "$ref": "./link.yml" + - description: Open this wiki page in the web application + readOnly: true addAttachment: allOf: - "$ref": "./link.yml" diff --git a/docs/api/apiv3/openapi-spec.yml b/docs/api/apiv3/openapi-spec.yml index 5212453005fb..5fa99df9ea50 100644 --- a/docs/api/apiv3/openapi-spec.yml +++ b/docs/api/apiv3/openapi-spec.yml @@ -607,6 +607,8 @@ paths: "$ref": "./paths/views.yml" "/api/v3/views/{id}": "$ref": "./paths/view.yml" + "/api/v3/wiki_pages": + "$ref": "./paths/wiki_pages.yml" "/api/v3/wiki_pages/{id}": "$ref": "./paths/wiki_page.yml" "/api/v3/wiki_pages/{id}/attachments": @@ -1240,6 +1242,8 @@ components: "$ref": "./components/schemas/week_day_write_model.yml" Wiki_PageModel: "$ref": "./components/schemas/wiki_page_model.yml" + WikiPageCollectionModel: + "$ref": "./components/schemas/wiki_page_collection_model.yml" WorkPackageModel: "$ref": "./components/schemas/work_package_model.yml" WorkPackageFormModel: diff --git a/docs/api/apiv3/paths/meetings.yml b/docs/api/apiv3/paths/meetings.yml index 6901a7126c19..6a4136d0ec00 100644 --- a/docs/api/apiv3/paths/meetings.yml +++ b/docs/api/apiv3/paths/meetings.yml @@ -7,6 +7,30 @@ get: - Meetings description: |- Retrieve a paginated collection of meetings visible to the authenticated user. + parameters: + - name: offset + in: query + required: false + description: Page number inside the requested collection. + schema: + type: integer + default: 1 + - name: pageSize + in: query + required: false + description: Number of elements to display per page. + schema: + type: integer + - name: filters + in: query + required: false + description: |- + JSON specifying filter conditions. The `search` filter with the `**` operator searches meeting titles, + agenda item titles and notes, and outcome notes. Results always remain restricted to meetings visible to + the authenticated user. + example: '[{ "search": { "operator": "**", "values": ["quarterly planning"] } }]' + schema: + type: string responses: '200': description: OK diff --git a/docs/api/apiv3/paths/wiki_pages.yml b/docs/api/apiv3/paths/wiki_pages.yml new file mode 100644 index 000000000000..d5e655228f84 --- /dev/null +++ b/docs/api/apiv3/paths/wiki_pages.yml @@ -0,0 +1,41 @@ +# /api/v3/wiki_pages +--- +get: + summary: List visible wiki pages + operationId: list_wiki_pages + tags: + - Wiki Pages + description: |- + Retrieve a paginated collection of wiki pages in projects where the authenticated user may view the wiki. + parameters: + - name: offset + in: query + required: false + description: Page number inside the requested collection. + schema: + type: integer + default: 1 + - name: pageSize + in: query + required: false + description: Number of elements to display per page. + schema: + type: integer + - name: filters + in: query + required: false + description: |- + JSON specifying filter conditions. The `search` filter with the `**` operator searches wiki page titles + and text. Results always remain restricted to pages visible to the authenticated user. + example: '[{ "search": { "operator": "**", "values": ["release plan"] } }]' + schema: + type: string + responses: + '200': + description: OK + content: + application/hal+json: + schema: + $ref: "../components/schemas/wiki_page_collection_model.yml" + '400': + $ref: "../components/responses/invalid_request_body.yml" diff --git a/docs/api/apiv3/tags/wiki_pages.yml b/docs/api/apiv3/tags/wiki_pages.yml index b4902b5b3c9a..1f02001ac1ff 100644 --- a/docs/api/apiv3/tags/wiki_pages.yml +++ b/docs/api/apiv3/tags/wiki_pages.yml @@ -2,8 +2,6 @@ description: |- Represents an individual page in a project's wiki. - *This resource is currently a stub* - ## Actions | Link | Description | Condition | @@ -17,6 +15,7 @@ description: |- | self | This wiki page | WikiPage | not null | READ | | attachments | The files attached to this wiki page | Collection | | READ | | project | The project the wiki page belongs to | Project | not null | READ | + | showWikiPage | Open the wiki page in the web application | Link | not null | READ | ## Local Properties diff --git a/frontend/src/app/core/apiv3/api-v3.service.ts b/frontend/src/app/core/apiv3/api-v3.service.ts index 2061c81607a9..9f3055466e82 100644 --- a/frontend/src/app/core/apiv3/api-v3.service.ts +++ b/frontend/src/app/core/apiv3/api-v3.service.ts @@ -126,6 +126,9 @@ export class ApiV3Service { // /api/v3/meetings public readonly meetings = this.apiV3CollectionEndpoint('meetings'); + // /api/v3/wiki_pages + public readonly wiki_pages = this.apiV3CollectionEndpoint('wiki_pages'); + // /api/v3/memberships public readonly memberships = this.apiV3CustomEndpoint(ApiV3MembershipsPaths); diff --git a/frontend/src/app/core/apiv3/endpoints/work_packages/api-v3-work-packages-paths.ts b/frontend/src/app/core/apiv3/endpoints/work_packages/api-v3-work-packages-paths.ts index c60a25fc0025..362278017c91 100644 --- a/frontend/src/app/core/apiv3/endpoints/work_packages/api-v3-work-packages-paths.ts +++ b/frontend/src/app/core/apiv3/endpoints/work_packages/api-v3-work-packages-paths.ts @@ -140,8 +140,13 @@ export class ApiV3WorkPackagesPaths extends ApiV3Collection = {}):ApiV3WorkPackageCachedSubresource { - const filters:ApiV3FilterBuilder = new ApiV3FilterBuilder(); + public filterByTypeaheadOrId( + term:string, + idOnly = false, + additionalParams:Record = {}, + additionalFilters:ApiV3FilterBuilder = new ApiV3FilterBuilder(), + ):ApiV3WorkPackageCachedSubresource { + const filters = additionalFilters.clone(); if (idOnly) { filters.add('id', '=', [term]); diff --git a/frontend/src/app/core/global_search/input/global-search-input.component.html b/frontend/src/app/core/global_search/input/global-search-input.component.html index 06ec9f6cff77..55d2c597d224 100644 --- a/frontend/src/app/core/global_search/input/global-search-input.component.html +++ b/frontend/src/app/core/global_search/input/global-search-input.component.html @@ -47,7 +47,7 @@ [searchFn]="customSearchFn" [virtualScroll]="false" (focus)="onFocus()" - (blur)="onFocusOut()" + (blur)="onFocusOut($event)" (search)="search($event)" (close)="onClose()" (change)="followItem($event)" @@ -63,32 +63,133 @@ @if (!searchTerm && (hasRecentItems$ | async)) {
{{text.recently_viewed}}
} +
+
+ @for (resultType of resultTypes; track resultType) { + + } +
+ +
+
+
+ {{ text.last_updated }} + @for (filter of lastUpdatedFilters; track filter) { + + } +
+ @if (activeResultType === 'work_packages') { +
+ {{ text.status }} + @for (filter of workPackageStatusFilters; track filter) { + + } +
+
+ {{ text.involvement }} + @for (filter of workPackageInvolvementFilters; track filter) { + + } +
+ } + @if (activeResultType === 'meetings') { +
+ {{ text.meeting_date }} + @for (filter of meetingTimeFilters; track filter) { + + } +
+
+ {{ text.involvement }} + @for (filter of meetingInvolvementFilters; track filter) { + + } +
+ } +
- @if (!item.id) { -
- -
{{ currentValue }}
-
{{ item.text }} ↵
-
+ + +
+
+ {{ resultTypeLabel(item) }} + @if (isWorkPackage(item)) { + + + {{ item.type.name }} + + {{ item.formattedId }} + + {{ item.status.name }} + + } +
+ {{ resultTitle(item) }} + {{ item.project.name }}
- } @else { -
- - - } +
diff --git a/frontend/src/app/core/global_search/input/global-search-input.component.sass b/frontend/src/app/core/global_search/input/global-search-input.component.sass index c6c3167d592e..e0f0687b25c1 100644 --- a/frontend/src/app/core/global_search/input/global-search-input.component.sass +++ b/frontend/src/app/core/global_search/input/global-search-input.component.sass @@ -2,7 +2,7 @@ // search bar has a min-width of 300px and should adapt to every screen size $search-input-width: calc(300px + 3vw) -$search-input-width-expanded: 500px +$search-input-width-expanded: min(740px, calc(100vw - 280px)) $search-input-height: 30px .top-menu-search @@ -109,6 +109,120 @@ $search-input-height: 30px &:empty display: none + .global-search--toolbar + display: flex + align-items: center + justify-content: space-between + margin: -0.5rem -0.75rem + padding: 0 var(--base-size-8) 0 var(--base-size-16) + border-bottom: 1px solid var(--borderColor-default) + text-transform: none + + .global-search--tabs + display: flex + gap: var(--base-size-24) + + .global-search--tab + @include unset-button-styles + padding: var(--base-size-12) 0 var(--base-size-8) + color: var(--fgColor-muted) + border-bottom: 2px solid transparent + font-weight: var(--base-text-weight-semibold) + + &:hover + color: var(--fgColor-default) + + &.-selected + color: var(--fgColor-default) + border-bottom-color: var(--borderColor-accent-emphasis) + + .global-search--project-scope-select + max-width: 230px + padding: var(--base-size-4) var(--base-size-24) var(--base-size-4) var(--base-size-8) + color: var(--fgColor-default) + background-color: var(--bgColor-default) + border: 1px solid var(--borderColor-default) + border-radius: var(--borderRadius-small) + font-size: var(--text-body-size-small) + + .global-search--quick-filters + display: flex + flex-direction: column + gap: var(--base-size-8) + margin: var(--base-size-8) -0.75rem -0.5rem + padding: var(--base-size-8) var(--base-size-16) + background-color: var(--bgColor-muted) + border-bottom: 1px solid var(--borderColor-default) + text-transform: none + + .global-search--quick-filter-group + display: flex + align-items: center + flex-wrap: wrap + gap: var(--base-size-8) + + .global-search--quick-filter-label + min-width: 105px + color: var(--fgColor-muted) + font-size: var(--text-body-size-small) + font-weight: var(--base-text-weight-semibold) + + .global-search--quick-filter + @include unset-button-styles + padding: var(--base-size-4) var(--base-size-8) + color: var(--fgColor-muted) + background-color: var(--control-transparent-bgColor-rest) + border: 1px solid transparent + border-radius: 999px + font-size: var(--text-body-size-small) + + &:hover + color: var(--fgColor-default) + background-color: var(--control-transparent-bgColor-hover) + + &.-selected + color: var(--fgColor-accent) + background-color: var(--bgColor-accent-muted) + border-color: var(--borderColor-accent-emphasis) + + .global-search--result-card, + .global-search--result-card:hover + display: flex + align-items: flex-start + gap: var(--base-size-12) + line-height: 1.25 + + .global-search--result-icon + display: flex + align-items: center + justify-content: center + width: var(--base-size-24) + min-width: var(--base-size-24) + height: var(--base-size-24) + margin-top: var(--base-size-4) + color: var(--fgColor-muted) + + .global-search--result-kind-line + display: flex + align-items: center + gap: var(--base-size-4) + min-height: var(--base-size-20) + color: var(--fgColor-muted) + font-size: var(--text-body-size-small) + + .global-search--result-kind + color: var(--fgColor-accent) + font-weight: var(--base-text-weight-semibold) + text-transform: uppercase + + .global-search--result-title + color: var(--fgColor-default) + font-weight: var(--base-text-weight-medium) + + .global-search--result-project + color: var(--fgColor-muted) + font-size: var(--text-body-size-small) + .ng-dropdown-panel-items.scroll-host @include styled-scroll-bar max-height: 80vh @@ -116,6 +230,8 @@ $search-input-height: 30px .ng-dropdown-panel width: $search-input-width-expanded !important + border-radius: 0 0 var(--borderRadius-medium) var(--borderRadius-medium) + box-shadow: var(--shadow-floating-small) .ng-option border-bottom: 1px solid var(--borderColor-default) diff --git a/frontend/src/app/core/global_search/input/global-search-input.component.spec.ts b/frontend/src/app/core/global_search/input/global-search-input.component.spec.ts index 5d8525cee405..75c36cf891be 100644 --- a/frontend/src/app/core/global_search/input/global-search-input.component.spec.ts +++ b/frontend/src/app/core/global_search/input/global-search-input.component.spec.ts @@ -28,14 +28,80 @@ import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { HalSource } from 'core-app/features/hal/interfaces'; import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; -import { GlobalSearchInputComponent } from './global-search-input.component'; +import { MeetingResource } from 'core-app/features/hal/resources/meeting-resource'; +import { WikiPageResource } from 'core-app/features/hal/resources/wiki-page-resource'; +import { + GlobalSearchInputComponent, + lastUpdatedRange, + searchResultMatchesType, +} from './global-search-input.component'; + +describe('lastUpdatedRange', () => { + const now = new Date(2026, 8, 5, 14, 30); + const startOfToday = new Date(2026, 8, 5); + + it('does not add a range for any time', () => { + expect(lastUpdatedRange('any_time', now)).toBeUndefined(); + }); + + it('uses the local start of today for today', () => { + expect(lastUpdatedRange('today', now)).toEqual([ + startOfToday.toISOString(), + '', + ]); + }); + + it('bounds yesterday between two local midnights', () => { + expect(lastUpdatedRange('yesterday', now)).toEqual([ + new Date(2026, 8, 4).toISOString(), + startOfToday.toISOString(), + ]); + }); + + it('uses rolling periods relative to the current time', () => { + expect(lastUpdatedRange('past_7_days', now)?.[0]).toBe( + new Date(2026, 7, 29, 14, 30).toISOString(), + ); + expect(lastUpdatedRange('past_30_days', now)?.[0]).toBe( + new Date(2026, 7, 6, 14, 30).toISOString(), + ); + expect(lastUpdatedRange('past_year', now)?.[0]).toBe( + new Date(2025, 8, 5, 14, 30).toISOString(), + ); + }); +}); + +describe('searchResultMatchesType', () => { + const workPackage = Object.create(WorkPackageResource.prototype) as WorkPackageResource; + const meeting = Object.create(MeetingResource.prototype) as MeetingResource; + const wikiPage = Object.create(WikiPageResource.prototype) as WikiPageResource; + + it('shows every resource in the all tab', () => { + expect([workPackage, meeting, wikiPage].every((item) => searchResultMatchesType('all', item))).toBe(true); + }); + + it('shows only work packages in the work packages tab', () => { + expect(searchResultMatchesType('work_packages', workPackage)).toBe(true); + expect(searchResultMatchesType('work_packages', meeting)).toBe(false); + expect(searchResultMatchesType('work_packages', wikiPage)).toBe(false); + }); + + it('shows only the selected content type in the other tabs', () => { + expect(searchResultMatchesType('meetings', meeting)).toBe(true); + expect(searchResultMatchesType('meetings', wikiPage)).toBe(false); + expect(searchResultMatchesType('wiki_pages', wikiPage)).toBe(true); + expect(searchResultMatchesType('wiki_pages', meeting)).toBe(false); + }); +}); // followItem is verified through the prototype against a stand-in context, avoiding // a real component instance whose many injected dependencies this branch never uses. describe('GlobalSearchInputComponent#followItem', () => { let wpPathArgs:string[]; - let searchInScopeArgs:string[]; - let context:Pick & { searchInScope:(scope:string) => void }; + let context:Pick< + GlobalSearchInputComponent, + 'wpPath'|'selectedItem'|'isWorkPackage'|'isMeeting'|'isWikiPage' + >; function callFollowItem(item:Parameters[0]):void { GlobalSearchInputComponent.prototype.followItem.call(context, item); @@ -43,7 +109,6 @@ describe('GlobalSearchInputComponent#followItem', () => { beforeEach(() => { wpPathArgs = []; - searchInScopeArgs = []; context = { wpPath: (id:string):string => { wpPathArgs.push(id); @@ -51,9 +116,9 @@ describe('GlobalSearchInputComponent#followItem', () => { return '#stub'; }, selectedItem: undefined, - searchInScope: (scope:string):void => { - searchInScopeArgs.push(scope); - }, + isWorkPackage: (item):item is WorkPackageResource => item instanceof WorkPackageResource, + isMeeting: (_item):_item is MeetingResource => false, + isWikiPage: (_item):_item is WikiPageResource => false, }; }); @@ -97,19 +162,46 @@ describe('GlobalSearchInputComponent#followItem', () => { }); }); - describe('when item is a scope option (not a HalResource)', () => { - it('delegates to searchInScope and does not call wpPath', () => { - callFollowItem({ projectScope: 'current_project', text: 'In this project ↵' }); - expect(searchInScopeArgs).toEqual(['current_project']); - expect(wpPathArgs).toEqual([]); - }); - }); - describe('when item is undefined', () => { it('does nothing', () => { callFollowItem(undefined); expect(wpPathArgs).toEqual([]); - expect(searchInScopeArgs).toEqual([]); }); }); }); + +describe('GlobalSearchInputComponent#onFocusOut', () => { + it('keeps the search open when focus moves to the project scope selector', () => { + const componentElement = document.createElement('div'); + const projectScopeSelect = document.createElement('select'); + componentElement.append(projectScopeSelect); + + let setOpenCalled = false; + const context = { + elementRef: { nativeElement: componentElement }, + ngSelectComponent: { + ngSelectInstance: { + isOpen: { + set: (_value:boolean):void => { + setOpenCalled = true; + }, + }, + }, + }, + }; + const event = { relatedTarget: null } as unknown as FocusEvent; + const mouseDownEvent = new MouseEvent('mousedown'); + + GlobalSearchInputComponent.prototype.onProjectScopeMouseDown.call( + context as unknown as GlobalSearchInputComponent, + mouseDownEvent, + ); + + GlobalSearchInputComponent.prototype.onFocusOut.call( + context as unknown as GlobalSearchInputComponent, + event, + ); + + expect(setOpenCalled).toBe(false); + }); +}); diff --git a/frontend/src/app/core/global_search/input/global-search-input.component.ts b/frontend/src/app/core/global_search/input/global-search-input.component.ts index d5f53b21a677..1cadbffeb28e 100644 --- a/frontend/src/app/core/global_search/input/global-search-input.component.ts +++ b/frontend/src/app/core/global_search/input/global-search-input.component.ts @@ -26,8 +26,9 @@ //++ import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, HostListener, Input, OnDestroy, ViewChild, ViewEncapsulation, inject } from '@angular/core'; -import { BehaviorSubject, Observable, of } from 'rxjs'; -import { first, map, switchMap } from 'rxjs/operators'; +import { BehaviorSubject, forkJoin, Observable, of } from 'rxjs'; +import { catchError, first, map, switchMap } from 'rxjs/operators'; +import { Highlighting } from 'core-app/features/work-packages/components/wp-fast-table/builders/highlighting/highlighting.functions'; import { GlobalSearchService } from 'core-app/core/global_search/services/global-search.service'; import { isClickedWithModifier } from 'core-app/shared/helpers/link-handling/link-handling'; import { DeviceService } from 'core-app/core/browser/device.service'; @@ -51,27 +52,60 @@ import { populateInputsFromDataset } from 'core-app/shared/components/dataset-in import { ApiV3FilterBuilder } from 'core-app/shared/helpers/api-v3/api-v3-filter-builder'; import { announce } from '@primer/live-region-element'; import { NgOption } from '@ng-select/ng-select'; +import { MeetingResource } from 'core-app/features/hal/resources/meeting-resource'; +import { WikiPageResource } from 'core-app/features/hal/resources/wiki-page-resource'; +import { CollectionResource } from 'core-app/features/hal/resources/collection-resource'; +import { CurrentUserService } from 'core-app/core/current-user/current-user.service'; -interface SearchResultItem { - id:string; - subject:string; - status:string; - statusId:string; - href:string; - project:string; - author:HalResource; -} +export type SearchResultType = 'all'|'work_packages'|'meetings'|'wiki_pages'; +type GlobalSearchResult = WorkPackageResource|MeetingResource|WikiPageResource; +type ProjectScope = 'all_projects'|'current_project'|'current_project_and_all_descendants'; +type LastUpdatedFilter = 'any_time'|'today'|'yesterday'|'past_7_days'|'past_30_days'|'past_year'; +type WorkPackageStatusFilter = 'all'|'open'|'closed'; +type WorkPackageInvolvementFilter = 'all'|'assigned_to_me'|'created_by_me'|'accountable'; +type MeetingTimeFilter = 'all'|'upcoming'|'past'; +type MeetingInvolvementFilter = 'all'|'invited'|'created_by_me'|'attended'; + +export function lastUpdatedRange( + filter:LastUpdatedFilter, + now = new Date(), +):[string, string]|undefined { + if (filter === 'any_time') { + return undefined; + } + + const startOfToday = new Date(now); + startOfToday.setHours(0, 0, 0, 0); + + if (filter === 'today') { + return [startOfToday.toISOString(), '']; + } + + if (filter === 'yesterday') { + const startOfYesterday = new Date(startOfToday); + startOfYesterday.setDate(startOfYesterday.getDate() - 1); + return [startOfYesterday.toISOString(), startOfToday.toISOString()]; + } -interface SearchOptionItem { - projectScope:string; - text:string; + const start = new Date(now); + if (filter === 'past_year') { + start.setFullYear(start.getFullYear() - 1); + } else { + start.setDate(start.getDate() - (filter === 'past_7_days' ? 7 : 30)); + } + + return [start.toISOString(), '']; } -interface SearchResultItems { - items:SearchResultItem[]|SearchOptionItem[]; - term:string; +export function searchResultMatchesType(resultType:SearchResultType, item:GlobalSearchResult):boolean { + return resultType === 'all' + || (resultType === 'work_packages' && item instanceof WorkPackageResource) + || (resultType === 'meetings' && item instanceof MeetingResource) + || (resultType === 'wiki_pages' && item instanceof WikiPageResource); } +type GlobalSearchItem = GlobalSearchResult; + @Component({ selector: 'opce-global-search', changeDetection: ChangeDetectionStrategy.OnPush, @@ -97,6 +131,7 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy { readonly cdRef = inject(ChangeDetectorRef); readonly halNotification = inject(HalResourceNotificationService); readonly recentItemsService = inject(RecentItemsService); + readonly currentUserService = inject(CurrentUserService); @Input() public placeholder:string; @@ -108,6 +143,11 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy { private _searchTermInitialized = false; + private currentSearchResults:GlobalSearchResult[] = []; + private currentResultQuery = ''; + private projectScopeInteraction = false; + private filterRefreshVersion = 0; + // Computed placeholder that changes based on expanded state public get effectivePlaceholder():string { return this.expanded @@ -123,7 +163,30 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy { map((items) => (items.length > 0)), ); - getAutocompleterData = ():Observable => this.autocompleteWorkPackages(); + public activeResultType:SearchResultType = 'all'; + public readonly resultTypes:SearchResultType[] = ['all', 'work_packages', 'meetings', 'wiki_pages']; + public readonly lastUpdatedFilters:LastUpdatedFilter[] = [ + 'any_time', 'today', 'yesterday', 'past_7_days', 'past_30_days', 'past_year', + ]; + + public readonly workPackageStatusFilters:WorkPackageStatusFilter[] = ['all', 'open', 'closed']; + public readonly workPackageInvolvementFilters:WorkPackageInvolvementFilter[] = [ + 'all', 'assigned_to_me', 'created_by_me', 'accountable', + ]; + + public readonly meetingTimeFilters:MeetingTimeFilter[] = ['all', 'upcoming', 'past']; + public readonly meetingInvolvementFilters:MeetingInvolvementFilter[] = [ + 'all', 'invited', 'created_by_me', 'attended', + ]; + + public selectedLastUpdatedFilter:LastUpdatedFilter = 'any_time'; + public selectedWorkPackageStatusFilter:WorkPackageStatusFilter = 'all'; + public selectedWorkPackageInvolvementFilter:WorkPackageInvolvementFilter = 'all'; + public selectedMeetingTimeFilter:MeetingTimeFilter = 'all'; + public selectedMeetingInvolvementFilter:MeetingInvolvementFilter = 'all'; + public selectedProjectScope:ProjectScope; + + getAutocompleterData = ():Observable => this.autocompleteGlobalSearch(); public autocompleterOptions = { filters: [], @@ -135,7 +198,7 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy { /** Remember the item that best matches the query. * That way, it will be highlighted (as we manually mark the selected item) and we can handle enter. * */ - public selectedItem:WorkPackageResource|SearchOptionItem|undefined = undefined; + public selectedItem:GlobalSearchItem|undefined = undefined; /** Remember the current value */ public currentValue = ''; @@ -147,16 +210,43 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy { private unregisterGlobalListener:(() => unknown)|undefined; public text:Record = { + accountable: this.I18n.t('js.global_search.quick_filters.accountable'), + all: this.I18n.t('js.label_all_uppercase'), all_projects: this.I18n.t('js.global_search.all_projects'), + any_time: this.I18n.t('js.global_search.quick_filters.any_time'), + assigned_to_me: this.I18n.t('js.work_packages.default_queries.assigned_to_me'), + attended: this.I18n.t('js.global_search.quick_filters.attended'), close_search: this.I18n.t('js.global_search.close_search'), + closed: this.I18n.t('js.global_search.quick_filters.closed'), + created_by_me: this.I18n.t('js.work_packages.default_queries.created_by_me'), current_project_and_all_descendants: this.I18n.t('js.global_search.current_project_and_all_descendants'), current_project: this.I18n.t('js.global_search.current_project'), recently_viewed: this.I18n.t('js.global_search.recently_viewed'), search: this.I18n.t('js.autocompleter.search'), + work_packages: this.I18n.t('js.label_work_package_plural'), + meetings: this.I18n.t('js.label_meetings'), + meeting_date: this.I18n.t('js.global_search.quick_filters.meeting_date'), + involvement: this.I18n.t('js.global_search.quick_filters.involvement'), + invited: this.I18n.t('js.global_search.quick_filters.invited'), + last_updated: this.I18n.t('js.label_last_updated_on'), + open: this.I18n.t('js.global_search.quick_filters.open'), + past: this.I18n.t('js.global_search.quick_filters.past'), + past_7_days: this.I18n.t('js.global_search.quick_filters.past_7_days'), + past_30_days: this.I18n.t('js.global_search.quick_filters.past_30_days'), + past_year: this.I18n.t('js.global_search.quick_filters.past_year'), + project: this.I18n.t('js.label_project'), + status: this.I18n.t('js.work_packages.properties.status'), + today: this.I18n.t('js.label_today'), + upcoming: this.I18n.t('js.global_search.quick_filters.upcoming'), + wiki_pages: this.I18n.t('js.global_search.wiki_pages'), + yesterday: this.I18n.t('js.global_search.quick_filters.yesterday'), }; constructor() { populateInputsFromDataset(this); + this.selectedProjectScope = this.currentProjectService.path + ? this.currentScope + : 'all_projects'; } ngAfterViewInit():void { @@ -201,7 +291,7 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy { } else if (this.searchTerm?.length === 0) { this.ngSelectComponent.ngSelectInstance.focus(); } else { - this.submitNonEmptySearch(''); + this.searchInScope(this.selectedProjectScope); } } } @@ -227,7 +317,12 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy { return this.pathHelperService.workPackagePath(id); } - public search(_$event:SearchResultItems):void { + public highlighting(property:string, id:string):string { + return Highlighting.inlineClass(property, id); + } + + public search(_$event:unknown):void { + this.filterRefreshVersion += 1; this.currentValue = this.searchTerm; } @@ -242,7 +337,15 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy { this.ngSelectComponent.openSelect(); } - public onFocusOut():void { + public onFocusOut(event?:FocusEvent):void { + const nextFocusedElement = event?.relatedTarget; + if ( + this.projectScopeInteraction + || (nextFocusedElement instanceof Node && this.elementRef.nativeElement.contains(nextFocusedElement)) + ) { + return; + } + if (!this.deviceService.isMobile) { this.expanded = (this.searchTerm !== null && this.searchTerm.length > 0); this.ngSelectComponent.ngSelectInstance.isOpen.set(false); @@ -275,12 +378,18 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy { }); } - public followItem(item:WorkPackageResource|SearchOptionItem|undefined):void { + public statusHighlighting(statusId:string):string { + return Highlighting.inlineClass('status', statusId); + } + + public followItem(item:GlobalSearchItem|undefined):void { this.selectedItem = item; - if (item instanceof WorkPackageResource) { + if (this.isWikiPage(item)) { + window.location.href = String(item.showWikiPagePath); + } else if (this.isWorkPackage(item)) { window.location.href = this.wpPath(item.displayId); - } else if (item) { - this.searchInScope((item as SearchOptionItem).projectScope); + } else if (this.isMeeting(item)) { + window.location.href = this.pathHelperService.meetingPath(item.id!); } } @@ -290,12 +399,128 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy { } } - // return all project scope items and all items which contain the search term - public customSearchFn(term:string, item:SearchResultItem):boolean { - return item.id === undefined || item.subject.toLowerCase().includes(term.toLowerCase()); + public customSearchFn(term:string, item:GlobalSearchItem):boolean { + if (!(item instanceof HalResource)) { + return true; + } + + if (!this.isActiveResult(item)) { + return false; + } + + return !this.isWorkPackage(item) + || item.subject.toLowerCase().includes(term.toLowerCase()); } - private autocompleteWorkPackages():Observable<(WorkPackageResource|SearchOptionItem)[]> { + public selectResultType(resultType:SearchResultType):void { + this.activeResultType = resultType; + this.refreshFilteredResults(); + } + + public selectLastUpdatedFilter(filter:LastUpdatedFilter):void { + this.selectedLastUpdatedFilter = filter; + this.refreshFilteredResults(); + } + + public selectWorkPackageStatusFilter(filter:WorkPackageStatusFilter):void { + this.selectedWorkPackageStatusFilter = filter; + this.refreshFilteredResults(); + } + + public selectWorkPackageInvolvementFilter(filter:WorkPackageInvolvementFilter):void { + this.selectedWorkPackageInvolvementFilter = filter; + this.refreshFilteredResults(); + } + + public selectMeetingTimeFilter(filter:MeetingTimeFilter):void { + this.selectedMeetingTimeFilter = filter; + this.refreshFilteredResults(); + } + + public selectMeetingInvolvementFilter(filter:MeetingInvolvementFilter):void { + this.selectedMeetingInvolvementFilter = filter; + this.refreshFilteredResults(); + } + + public selectProjectScope(event:Event):void { + this.selectedProjectScope = (event.target as HTMLSelectElement).value as ProjectScope; + } + + public onProjectScopeMouseDown(event:MouseEvent):void { + this.projectScopeInteraction = true; + event.stopPropagation(); + } + + public onProjectScopeBlur():void { + this.projectScopeInteraction = false; + } + + public get projectScopes():ProjectScope[] { + return this.currentProjectService.path + ? ['current_project_and_all_descendants', 'current_project', 'all_projects'] + : ['all_projects']; + } + + public resultPath(item:GlobalSearchResult):string { + if (this.isWikiPage(item)) { + return String(item.showWikiPagePath); + } + + if (this.isWorkPackage(item)) { + return this.wpPath(item.displayId); + } + + if (this.isMeeting(item)) { + return this.pathHelperService.meetingPath(item.id!); + } + + return '#'; + } + + public resultTitle(item:GlobalSearchResult):string { + return this.isWorkPackage(item) ? item.subject : item.title; + } + + public resultIcon(item:GlobalSearchResult):string { + if (this.isWorkPackage(item)) { + return 'op-view-list'; + } + + return this.isMeeting(item) ? 'comment-discussion' : 'book'; + } + + public resultTypeLabel(item:GlobalSearchResult):string { + if (this.isWorkPackage(item)) { + return this.text.work_packages; + } + + return this.isMeeting(item) ? this.text.meetings : this.text.wiki_pages; + } + + public isWorkPackage(item:GlobalSearchItem|undefined):item is WorkPackageResource { + return item instanceof WorkPackageResource; + } + + public isMeeting(item:GlobalSearchItem|undefined):item is MeetingResource { + return item instanceof MeetingResource; + } + + public isWikiPage(item:GlobalSearchItem|undefined):item is WikiPageResource { + return item instanceof WikiPageResource; + } + + public redirectToResult(item:GlobalSearchResult, event:MouseEvent):boolean { + event.stopImmediatePropagation(); + if (isClickedWithModifier(event)) { + return true; + } + + window.location.href = this.resultPath(item); + event.preventDefault(); + return false; + } + + private autocompleteGlobalSearch():Observable { // ng-select v21 initializes _searchTerm as null (signal). Treat null as '' so that // the initial typeahead emission triggers loadRecentItems() instead of returning empty. const query = this.searchTerm ?? ''; @@ -315,12 +540,39 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy { const hashFreeQuery = this.queryWithoutHash(query); - return this - .fetchSearchResults(hashFreeQuery, hashFreeQuery !== query) - .get() - .pipe( - map((collection) => this.searchResultsToOptions(collection.elements, hashFreeQuery)) - ); + const workPackageFilters = this.workPackageFilters(); + const meetingFilters = new ApiV3FilterBuilder().add('search', '**', [query]); + const wikiPageFilters = new ApiV3FilterBuilder().add('search', '**', [query]); + this.applyLastUpdatedFilter(workPackageFilters); + this.applyLastUpdatedFilter(meetingFilters); + this.applyLastUpdatedFilter(wikiPageFilters); + this.applyMeetingQuickFilters(meetingFilters); + const params = { pageSize: '20' }; + + return forkJoin({ + workPackages: this.fetchSearchResults(hashFreeQuery, hashFreeQuery !== query, workPackageFilters).get().pipe( + map((collection) => collection.elements), + ), + meetings: this.apiV3Service.meetings.filtered(meetingFilters, params).get().pipe( + map((collection:CollectionResource) => collection.elements.filter( + (item):item is MeetingResource => item instanceof MeetingResource, + )), + catchError(() => of([] as MeetingResource[])), + ), + wikiPages: this.apiV3Service.wiki_pages.filtered(wikiPageFilters, params).get().pipe( + map((collection:CollectionResource) => collection.elements.filter( + (item):item is WikiPageResource => item instanceof WikiPageResource, + )), + catchError(() => of([] as WikiPageResource[])), + ), + }).pipe( + map(({ workPackages, meetings, wikiPages }) => { + this.currentSearchResults = [...workPackages, ...meetings, ...wikiPages]; + this.currentResultQuery = hashFreeQuery; + + return this.searchResultsToOptions(this.currentSearchResults, this.currentResultQuery); + }), + ); } private loadRecentItems() { @@ -335,7 +587,8 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy { // Ensure we only load the five recent items // in case none of them are available in the cache - const filters = new ApiV3FilterBuilder().add('id', '=', wpIds); + const filters = this.workPackageFilters().add('id', '=', wpIds); + this.applyLastUpdatedFilter(filters); const params = { offset: '1', pageSize: '5', @@ -351,7 +604,11 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy { map((collection) => { // In case none of the wpIds exist anymore or are not accessible // this API call would return five arbitrary work packages, as that's the way valid_subset works - return collection.elements.filter((wp) => wpIds.includes(wp.id!)); + const recentWorkPackages = collection.elements.filter((wp) => wpIds.includes(wp.id!)); + this.currentSearchResults = recentWorkPackages; + this.currentResultQuery = ''; + + return this.searchResultsToOptions(recentWorkPackages, ''); }) ); }), @@ -366,48 +623,117 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy { return query; } - private fetchSearchResults(query:string, idOnly:boolean):ApiV3WorkPackageCachedSubresource { + private fetchSearchResults( + query:string, + idOnly:boolean, + filters:ApiV3FilterBuilder, + ):ApiV3WorkPackageCachedSubresource { return this .apiV3Service .work_packages - .filterByTypeaheadOrId(query, idOnly, { pageSize: '20' }); + .filterByTypeaheadOrId(query, idOnly, { pageSize: '20' }, filters); } - private searchResultsToOptions(results:WorkPackageResource[], query:string) { - const searchOptions = this.detailedSearchOptions(); + private refreshFilteredResults():void { + const refreshVersion = this.filterRefreshVersion + 1; + this.filterRefreshVersion = refreshVersion; + this.ngSelectComponent.loading$.next(true); + this.ngSelectComponent.updateItems([]); + + this.autocompleteGlobalSearch().pipe(first()).subscribe({ + next: (items) => { + if (refreshVersion !== this.filterRefreshVersion) { + return; + } + + this.ngSelectComponent.updateItems(items); + this.ngSelectComponent.loading$.next(false); + this.cdRef.detectChanges(); + }, + error: () => { + if (refreshVersion === this.filterRefreshVersion) { + this.ngSelectComponent.loading$.next(false); + this.cdRef.detectChanges(); + } + }, + }); + } + + private workPackageFilters():ApiV3FilterBuilder { + const filters = new ApiV3FilterBuilder(); + if (this.activeResultType !== 'work_packages') { + return filters; + } + + if (this.selectedWorkPackageStatusFilter === 'open') { + filters.add('status', 'o', []); + } else if (this.selectedWorkPackageStatusFilter === 'closed') { + filters.add('status', 'c', []); + } + + const involvementFilters = { + assigned_to_me: 'assignee', + created_by_me: 'author', + accountable: 'responsible', + } as const; + if (this.selectedWorkPackageInvolvementFilter !== 'all') { + filters.add(involvementFilters[this.selectedWorkPackageInvolvementFilter], '=', ['me']); + } + + return filters; + } + + private applyMeetingQuickFilters(filters:ApiV3FilterBuilder):void { + if (this.activeResultType !== 'meetings') { + return; + } + + if (this.selectedMeetingTimeFilter !== 'all') { + filters.add('time', this.selectedMeetingTimeFilter, []); + } + + const userId = this.currentUserService.userId; + const involvementFilters = { + invited: 'invitedUser', + created_by_me: 'author', + attended: 'attendedUser', + } as const; + if (userId && this.selectedMeetingInvolvementFilter !== 'all') { + filters.add(involvementFilters[this.selectedMeetingInvolvementFilter], '=', [userId]); + } + } + + private applyLastUpdatedFilter(filters:ApiV3FilterBuilder):void { + const range = lastUpdatedRange(this.selectedLastUpdatedFilter); + if (range) { + filters.add('updatedAt', '<>d', range); + } + } + + private searchResultsToOptions(results:GlobalSearchResult[], query:string) { // If we have a direct hit, we choose it to be the selected element. - this.selectedItem = results.find((wp) => wp.id?.toString() === query) ?? searchOptions[0]; + const visibleResults = results.filter((item) => this.isActiveResult(item)); + const directHit = ['all', 'work_packages'].includes(this.activeResultType) + ? results.find((item) => item instanceof WorkPackageResource && item.id?.toString() === query) + : undefined; + this.selectedItem = directHit; - if (this.selectedItem instanceof WorkPackageResource) { + if (directHit) { void announce(this.I18n.t('js.global_search.direct_hit_available'), { politeness: 'polite' }); this.setMarkedOption(); } else { - const resultCount = results.length + searchOptions.length; + const resultCount = visibleResults.length; void announce(this.I18n.t('js.global_search.items_available', { count: resultCount }), { politeness: 'polite' }); } return [ - ...searchOptions, - ...results, + ...visibleResults, ]; } - // set the possible 'search in scope' options for the current project path - private detailedSearchOptions():{ projectScope:string; text:string }[] { - const searchOptions = []; - // add all options when searching within a project - // otherwise search in 'all projects' - if (this.currentProjectService.path) { - searchOptions.push('current_project_and_all_descendants'); - searchOptions.push('current_project'); - } - if (this.currentScope === 'current_project') { - searchOptions.reverse(); - } - searchOptions.push('all_projects'); - - return searchOptions.map((suggestion:string) => ({ projectScope: suggestion, text: this.text[suggestion] })); + private isActiveResult(item:GlobalSearchResult):boolean { + return searchResultMatchesType(this.activeResultType, item); } /* @@ -444,7 +770,7 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy { this.cdRef.detectChanges(); } - private searchInScope(scope:string):void { + private searchInScope(scope:ProjectScope):void { switch (scope) { case 'all_projects': { this.submitNonEmptySearch('all'); @@ -466,14 +792,18 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy { public submitNonEmptySearch(scope:string):void { if (this.currentValue.length > 0) { this.ngSelectComponent.ngSelectInstance.close(); - this.globalSearchService.submitSearch(this.currentValue, scope); + this.globalSearchService.submitSearch(this.currentValue, scope, this.activeResultType); } } - private get currentScope():string { + private get currentScope():ProjectScope { const params = new URLSearchParams(window.location.search); - const serviceScope = params.get('scope') ?? ''; - return (serviceScope === '') ? 'current_project_and_all_descendants' : serviceScope; + const serviceScope = params.get('scope'); + return serviceScope === 'all' || serviceScope === 'all_projects' + ? 'all_projects' + : serviceScope === 'current_project' + ? 'current_project' + : 'current_project_and_all_descendants'; } private get currentQuery():string|null { diff --git a/frontend/src/app/core/global_search/openproject-global-search.module.ts b/frontend/src/app/core/global_search/openproject-global-search.module.ts index 3e887e2d8e96..b27f2f97fd18 100644 --- a/frontend/src/app/core/global_search/openproject-global-search.module.ts +++ b/frontend/src/app/core/global_search/openproject-global-search.module.ts @@ -35,12 +35,14 @@ import { } from 'core-app/shared/components/autocompleter/openproject-autocompleter.module'; import { OpSharedModule } from 'core-app/shared/shared.module'; import { RecentItemsService } from 'core-app/core/recent-items.service'; +import { DynamicIconDirective } from 'core-app/shared/components/primer/dynamic-icon.directive'; @NgModule({ imports: [ OpSharedModule, OpenprojectWorkPackagesModule, OpenprojectAutocompleterModule, + DynamicIconDirective, ], providers: [ GlobalSearchService, diff --git a/frontend/src/app/core/global_search/services/global-search.service.ts b/frontend/src/app/core/global_search/services/global-search.service.ts index 9b92205a4888..1249f512ddb5 100644 --- a/frontend/src/app/core/global_search/services/global-search.service.ts +++ b/frontend/src/app/core/global_search/services/global-search.service.ts @@ -39,9 +39,9 @@ export class GlobalSearchService { protected currentProjectService = inject(CurrentProjectService); - public submitSearch(query:string, scope:string):void { + public submitSearch(query:string, scope:string, filter = 'work_packages'):void { const path = this.searchPath(scope); - const params = this.searchQueryParams(query, scope); + const params = this.searchQueryParams(query, scope, filter); window.location.href = `${path}?${params}`; } @@ -53,14 +53,15 @@ export class GlobalSearchService { return `${searchPath}/search`; } - private searchQueryParams(query:string, scope:string):string { + private searchQueryParams(query:string, scope:string, filter:string):string { const params = new URLSearchParams(window.location.search); params.set('q', query); params.set('scope', scope); - // Filter work packages by default - if (!params.get('filter')) { - params.set('filter', 'work_packages'); + if (filter === 'all') { + params.delete('filter'); + } else { + params.set('filter', filter); } return params.toString(); diff --git a/frontend/src/app/features/hal/resources/meeting-resource.ts b/frontend/src/app/features/hal/resources/meeting-resource.ts index 65a7250ad86a..92bf4fd992ea 100644 --- a/frontend/src/app/features/hal/resources/meeting-resource.ts +++ b/frontend/src/app/features/hal/resources/meeting-resource.ts @@ -28,6 +28,7 @@ import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; import { HalResource, HalResourceLinks } from 'core-app/features/hal/resources/hal-resource'; import { Attachable } from 'core-app/features/hal/resources/mixins/attachable-mixin'; +import { ProjectResource } from 'core-app/features/hal/resources/project-resource'; interface MeetingResourceLinks { addAttachment:CallableHalLink; @@ -35,7 +36,8 @@ interface MeetingResourceLinks { class MeetingBaseResource extends HalResource { title:string; - project:HalResource; + project:ProjectResource; + updatedAt:Date; public $links:MeetingResourceLinks & HalResourceLinks; } diff --git a/frontend/src/app/features/hal/resources/wiki-page-resource.ts b/frontend/src/app/features/hal/resources/wiki-page-resource.ts index 317968453ff9..2b389b9cd544 100644 --- a/frontend/src/app/features/hal/resources/wiki-page-resource.ts +++ b/frontend/src/app/features/hal/resources/wiki-page-resource.ts @@ -28,13 +28,24 @@ import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; import { HalResource, HalResourceLinks } from 'core-app/features/hal/resources/hal-resource'; import { Attachable } from 'core-app/features/hal/resources/mixins/attachable-mixin'; +import { ProjectResource } from 'core-app/features/hal/resources/project-resource'; export interface WikiPageResourceLinks { addAttachment:CallableHalLink; } class WikiPageBaseResource extends HalResource { + title:string; + project:ProjectResource; + updatedAt:Date; + showWikiPage:HalResource; public $links:WikiPageResourceLinks & HalResourceLinks; + + public get showWikiPagePath():string { + return this.showWikiPage?.$link.href ?? '#'; + } } export const WikiPageResource = Attachable(WikiPageBaseResource); + +export interface WikiPageResource extends WikiPageBaseResource, WikiPageResourceLinks {} diff --git a/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.ts b/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.ts index aef96e942c52..4c64869fd5e9 100644 --- a/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.ts +++ b/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.ts @@ -347,6 +347,10 @@ export class OpAutocompleterComponent = { x: xIconData, star: starIconData, 'circle-slash': circleSlashIconData, graph: graphIconData, + book: bookIconData, + 'comment-discussion': commentDiscussionIconData, + 'op-view-list': opViewListIconData, // TODO add more icons }; diff --git a/frontend/src/app/shared/helpers/api-v3/api-v3-filter-builder.ts b/frontend/src/app/shared/helpers/api-v3/api-v3-filter-builder.ts index f42692a21135..f87ea6608e48 100644 --- a/frontend/src/app/shared/helpers/api-v3/api-v3-filter-builder.ts +++ b/frontend/src/app/shared/helpers/api-v3/api-v3-filter-builder.ts @@ -27,7 +27,7 @@ import { pickBy } from 'lodash-es'; -export type FilterOperator = '='|'!*'|'!'|'~'|'o'|'>t-'|'<>d'|'**'|'ow'; +export type FilterOperator = '='|'!*'|'!'|'~'|'o'|'c'|'>t-'|'<>d'|'**'|'ow'|'upcoming'|'past'; export const FalseValue = ['f']; export const TrueValue = ['t']; diff --git a/lib/api/v3/utilities/endpoints/index.rb b/lib/api/v3/utilities/endpoints/index.rb index 9000956d1b4e..c55c5e2ca697 100644 --- a/lib/api/v3/utilities/endpoints/index.rb +++ b/lib/api/v3/utilities/endpoints/index.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -33,10 +35,12 @@ class Index < API::Utilities::Endpoints::Index def initialize(model:, api_name: model.name.demodulize, scope: nil, + query_class: nil, render_representer: nil, self_path: api_name.underscore.pluralize) super(model:, api_name:, scope:, render_representer:) + self.query_class = query_class self.self_path = self_path end @@ -52,7 +56,7 @@ def mount def parse(request) ParamsToQueryService - .new(model, request.current_user) + .new(model, request.current_user, query_class:) .call(request.params) end @@ -70,6 +74,7 @@ def render(request, query) attr_accessor :model, :api_name, :scope, + :query_class, :render_representer, :self_path diff --git a/lib/api/v3/utilities/path_helper.rb b/lib/api/v3/utilities/path_helper.rb index 40a7bd0b03b9..fa38f8d72d90 100644 --- a/lib/api/v3/utilities/path_helper.rb +++ b/lib/api/v3/utilities/path_helper.rb @@ -691,6 +691,12 @@ def self.wiki_page(id) "#{root}/wiki_pages/#{id}" end + index :wiki_page + + def self.show_wiki_page(project_identifier, slug) + project_wiki_path(project_identifier, slug) + end + resources :work_package, except: :schema def self.work_package(id, timestamps: nil) diff --git a/lib/api/v3/wiki_pages/wiki_page_collection_representer.rb b/lib/api/v3/wiki_pages/wiki_page_collection_representer.rb new file mode 100644 index 000000000000..0f39d3f2c9ca --- /dev/null +++ b/lib/api/v3/wiki_pages/wiki_page_collection_representer.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module API + module V3 + module WikiPages + class WikiPageCollectionRepresenter < ::API::Decorators::OffsetPaginatedCollection; end + end + end +end diff --git a/lib/api/v3/wiki_pages/wiki_page_representer.rb b/lib/api/v3/wiki_pages/wiki_page_representer.rb index be5ab6d4d0c0..fc7d033fa7c1 100644 --- a/lib/api/v3/wiki_pages/wiki_page_representer.rb +++ b/lib/api/v3/wiki_pages/wiki_page_representer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -36,10 +38,21 @@ class WikiPageRepresenter < ::API::Decorators::Single self_link title_getter: ->(*) {} + link :showWikiPage do + next unless represented.project && represented.slug.present? + + { + href: api_v3_paths.show_wiki_page(represented.project.identifier, represented.slug), + type: "text/html" + } + end + property :id property :title + date_time_property :updated_at + associated_project def _type diff --git a/lib/api/v3/wiki_pages/wiki_pages_api.rb b/lib/api/v3/wiki_pages/wiki_pages_api.rb index 5a596eafe8bc..ee69cd88f75b 100644 --- a/lib/api/v3/wiki_pages/wiki_pages_api.rb +++ b/lib/api/v3/wiki_pages/wiki_pages_api.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -30,6 +32,13 @@ module V3 module WikiPages class WikiPagesAPI < ::API::OpenProjectAPI resources :wiki_pages do + get &::API::V3::Utilities::Endpoints::Index + .new( + model: WikiPage, + query_class: ::Queries::Wikis::WikiPages::WikiPageQuery + ) + .mount + helpers do def wiki_page WikiPage.visible(current_user).find(params[:id]) diff --git a/modules/meeting/app/models/queries/meetings.rb b/modules/meeting/app/models/queries/meetings.rb index 20058e8d2b46..a1238312e9c3 100644 --- a/modules/meeting/app/models/queries/meetings.rb +++ b/modules/meeting/app/models/queries/meetings.rb @@ -38,6 +38,8 @@ module Queries::Meetings filter Filters::RecurringFilter filter Filters::TitleFilter filter Filters::StateFilter + filter Filters::SearchFilter + filter Filters::UpdatedAtFilter order Orders::DefaultOrder end diff --git a/modules/meeting/app/models/queries/meetings/filters/search_filter.rb b/modules/meeting/app/models/queries/meetings/filters/search_filter.rb new file mode 100644 index 000000000000..7e01cde9b972 --- /dev/null +++ b/modules/meeting/app/models/queries/meetings/filters/search_filter.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Queries::Meetings + module Filters + class SearchFilter < MeetingFilter + def type = :search + + def human_name = I18n.t("label_search") + + def apply_to(query_scope) + super.distinct + end + + def where + values.first.split(/\s+/).map do |token| + condition = searchable_columns.map do |table, column| + ::Queries::Operators::Contains.sql_for_field([token], table, column) + end.join(" OR ") + + "(#{condition})" + end.join(" AND ") + end + + def left_outer_joins + { agenda_items: :outcomes } + end + + private + + def searchable_columns + [ + [Meeting.table_name, "title"], + [MeetingAgendaItem.table_name, "title"], + [MeetingAgendaItem.table_name, "notes"], + [MeetingOutcome.table_name, "notes"] + ] + end + end + end +end diff --git a/modules/meeting/app/models/queries/meetings/filters/updated_at_filter.rb b/modules/meeting/app/models/queries/meetings/filters/updated_at_filter.rb new file mode 100644 index 000000000000..d7b34a423e5e --- /dev/null +++ b/modules/meeting/app/models/queries/meetings/filters/updated_at_filter.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Queries::Meetings::Filters::UpdatedAtFilter < Queries::Meetings::Filters::MeetingFilter + def type = :datetime_past +end diff --git a/modules/meeting/spec/models/queries/meetings/filters/updated_at_filter_spec.rb b/modules/meeting/spec/models/queries/meetings/filters/updated_at_filter_spec.rb new file mode 100644 index 000000000000..68e6dbeafe68 --- /dev/null +++ b/modules/meeting/spec/models/queries/meetings/filters/updated_at_filter_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe Queries::Meetings::Filters::UpdatedAtFilter do + it_behaves_like "basic query filter" do + let(:type) { :datetime_past } + let(:class_key) { :updated_at } + + describe "#available?" do + it "is true" do + expect(instance).to be_available + end + end + + describe "#allowed_values" do + it "is nil" do + expect(instance.allowed_values).to be_nil + end + end + + it_behaves_like "non ar filter" + end +end diff --git a/modules/meeting/spec/requests/api/v3/meetings/meetings_resource_spec.rb b/modules/meeting/spec/requests/api/v3/meetings/meetings_resource_spec.rb index c8da6db5918d..6e7943047600 100644 --- a/modules/meeting/spec/requests/api/v3/meetings/meetings_resource_spec.rb +++ b/modules/meeting/spec/requests/api/v3/meetings/meetings_resource_spec.rb @@ -47,6 +47,46 @@ login_as current_user end + describe "GET /api/v3/meetings" do + let(:path) { api_v3_paths.meetings } + let!(:matching_meeting) { create(:meeting, project:, title: "Quarterly planning") } + let!(:non_matching_meeting) { create(:meeting, project:, title: "Retrospective") } + let!(:invisible_meeting) { create(:meeting, project: other_project, title: "Quarterly planning") } + let!(:agenda_item) do + create(:meeting_agenda_item, meeting: matching_meeting, title: "Launch readiness", notes: "Review blockers") + end + + before do + create(:meeting_outcome, meeting_agenda_item: agenda_item, notes: "Ship on Friday") + get path, filters: [{ search: { operator: "**", values: [search_term] } }].to_json + end + + context "when the title matches" do + let(:search_term) { "quarterly" } + + it "returns only matching visible meetings" do + expect(last_response).to have_http_status(:ok) + expect(JSON.parse(last_response.body).dig("_embedded", "elements").pluck("id")).to eq([matching_meeting.id]) + end + end + + context "when agenda content matches" do + let(:search_term) { "launch blockers" } + + it "searches agenda titles and notes" do + expect(JSON.parse(last_response.body).dig("_embedded", "elements").pluck("id")).to eq([matching_meeting.id]) + end + end + + context "when an outcome matches" do + let(:search_term) { "ship Friday" } + + it "searches outcome notes without duplicating meetings" do + expect(JSON.parse(last_response.body).dig("_embedded", "elements").pluck("id")).to eq([matching_meeting.id]) + end + end + end + describe "GET /api/v3/meetings/:id" do let(:meeting) { create(:meeting, project:, author: current_user) } let(:get_path) { api_v3_paths.meeting meeting.id } diff --git a/modules/wikis/app/models/queries/wikis/wiki_pages/filter/search_filter.rb b/modules/wikis/app/models/queries/wikis/wiki_pages/filter/search_filter.rb new file mode 100644 index 000000000000..a8710ac68791 --- /dev/null +++ b/modules/wikis/app/models/queries/wikis/wiki_pages/filter/search_filter.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Queries + module Wikis + module WikiPages + module Filter + class SearchFilter < Filters::Base + self.model = ::WikiPage + + def type = :search + + def human_name = I18n.t("label_search") + + def where + values.first.split(/\s+/).map do |token| + condition = searchable_columns.map do |column| + ::Queries::Operators::Contains.sql_for_field([token], ::WikiPage.table_name, column) + end.join(" OR ") + + "(#{condition})" + end.join(" AND ") + end + + private + + def searchable_columns + %w[title text] + end + end + end + end + end +end diff --git a/modules/wikis/app/models/queries/wikis/wiki_pages/filter/updated_at_filter.rb b/modules/wikis/app/models/queries/wikis/wiki_pages/filter/updated_at_filter.rb new file mode 100644 index 000000000000..d154f8e9b744 --- /dev/null +++ b/modules/wikis/app/models/queries/wikis/wiki_pages/filter/updated_at_filter.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Queries + module Wikis + module WikiPages + module Filter + class UpdatedAtFilter < Filters::Base + self.model = ::WikiPage + + def type = :datetime_past + + def human_name = ::WikiPage.human_attribute_name(:updated_at) + end + end + end + end +end diff --git a/modules/wikis/lib/open_project/wikis/engine.rb b/modules/wikis/lib/open_project/wikis/engine.rb index 0dfceb9cf018..5b2c5a8bfbba 100644 --- a/modules/wikis/lib/open_project/wikis/engine.rb +++ b/modules/wikis/lib/open_project/wikis/engine.rb @@ -82,6 +82,7 @@ class Engine < ::Rails::Engine ::Queries::Register.register(::Queries::Wikis::WikiPages::WikiPageQuery) do filter ::Queries::Wikis::WikiPages::Filter::NameFilter + filter ::Queries::Wikis::WikiPages::Filter::SearchFilter end end diff --git a/modules/wikis/spec/models/queries/wikis/wiki_pages/filter/updated_at_filter_spec.rb b/modules/wikis/spec/models/queries/wikis/wiki_pages/filter/updated_at_filter_spec.rb new file mode 100644 index 000000000000..1fa16879d30e --- /dev/null +++ b/modules/wikis/spec/models/queries/wikis/wiki_pages/filter/updated_at_filter_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe Queries::Wikis::WikiPages::Filter::UpdatedAtFilter do + it_behaves_like "basic query filter" do + let(:type) { :datetime_past } + let(:class_key) { :updated_at } + let(:human_name) { WikiPage.human_attribute_name(:updated_at) } + + describe "#available?" do + it "is true" do + expect(instance).to be_available + end + end + + describe "#allowed_values" do + it "is nil" do + expect(instance.allowed_values).to be_nil + end + end + + it_behaves_like "non ar filter" + end +end diff --git a/spec/requests/api/v3/wiki_pages_resource_spec.rb b/spec/requests/api/v3/wiki_pages_resource_spec.rb index f5c9956fbe2c..475fe6e7894e 100644 --- a/spec/requests/api/v3/wiki_pages_resource_spec.rb +++ b/spec/requests/api/v3/wiki_pages_resource_spec.rb @@ -55,6 +55,34 @@ login_as(current_user) end + describe "GET /api/v3/wiki_pages" do + let(:path) { api_v3_paths.wiki_pages } + let!(:matching_page) { create(:wiki_page, wiki:, title: "Release plan", text: "Launch checklist") } + let!(:non_matching_page) { create(:wiki_page, wiki:, title: "Retrospective", text: "Team notes") } + let!(:invisible_page) { create(:wiki_page, wiki: other_wiki, title: "Release plan") } + + before do + get path, filters: [{ search: { operator: "**", values: [search_term] } }].to_json + end + + context "when the title matches" do + let(:search_term) { "release" } + + it "returns only matching visible wiki pages" do + expect(subject).to have_http_status(:ok) + expect(JSON.parse(subject.body).dig("_embedded", "elements").pluck("id")).to eq([matching_page.id]) + end + end + + context "when the body matches" do + let(:search_term) { "launch checklist" } + + it "searches the page body" do + expect(JSON.parse(subject.body).dig("_embedded", "elements").pluck("id")).to eq([matching_page.id]) + end + end + end + describe "GET /api/v3/wiki_pages/:id" do let(:path) { api_v3_paths.wiki_page(wiki_page.id) } From 922d78b013f415575349838fc576d900974cf595 Mon Sep 17 00:00:00 2001 From: Niels Lindenthal Date: Sun, 6 Sep 2026 10:41:50 +0200 Subject: [PATCH 2/6] Use Primer controls for global search filters --- .../input/global-search-input.component.html | 142 ++++++++++-------- .../input/global-search-input.component.sass | 32 +--- .../input/global-search-input.component.ts | 26 ++-- 3 files changed, 105 insertions(+), 95 deletions(-) diff --git a/frontend/src/app/core/global_search/input/global-search-input.component.html b/frontend/src/app/core/global_search/input/global-search-input.component.html index 55d2c597d224..8879d0fd5358 100644 --- a/frontend/src/app/core/global_search/input/global-search-input.component.html +++ b/frontend/src/app/core/global_search/input/global-search-input.component.html @@ -92,74 +92,94 @@
-
- {{ text.last_updated }} - @for (filter of lastUpdatedFilters; track filter) { - - } -
+ @if (activeResultType === 'work_packages') { -
+
-
+ + + + +
+ + + + } @if (activeResultType === 'meetings') { -
+
-
+ + + + +
+ + + + }
diff --git a/frontend/src/app/core/global_search/input/global-search-input.component.sass b/frontend/src/app/core/global_search/input/global-search-input.component.sass index e0f0687b25c1..5545a2d76514 100644 --- a/frontend/src/app/core/global_search/input/global-search-input.component.sass +++ b/frontend/src/app/core/global_search/input/global-search-input.component.sass @@ -147,7 +147,8 @@ $search-input-height: 30px .global-search--quick-filters display: flex - flex-direction: column + align-items: flex-end + flex-wrap: wrap gap: var(--base-size-8) margin: var(--base-size-8) -0.75rem -0.5rem padding: var(--base-size-8) var(--base-size-16) @@ -155,36 +156,19 @@ $search-input-height: 30px border-bottom: 1px solid var(--borderColor-default) text-transform: none - .global-search--quick-filter-group + .global-search--quick-filter-field display: flex - align-items: center - flex-wrap: wrap - gap: var(--base-size-8) + flex-direction: column + gap: var(--base-size-4) + + .FormControl-select-wrap + min-width: 150px .global-search--quick-filter-label - min-width: 105px color: var(--fgColor-muted) font-size: var(--text-body-size-small) font-weight: var(--base-text-weight-semibold) - .global-search--quick-filter - @include unset-button-styles - padding: var(--base-size-4) var(--base-size-8) - color: var(--fgColor-muted) - background-color: var(--control-transparent-bgColor-rest) - border: 1px solid transparent - border-radius: 999px - font-size: var(--text-body-size-small) - - &:hover - color: var(--fgColor-default) - background-color: var(--control-transparent-bgColor-hover) - - &.-selected - color: var(--fgColor-accent) - background-color: var(--bgColor-accent-muted) - border-color: var(--borderColor-accent-emphasis) - .global-search--result-card, .global-search--result-card:hover display: flex diff --git a/frontend/src/app/core/global_search/input/global-search-input.component.ts b/frontend/src/app/core/global_search/input/global-search-input.component.ts index 1cadbffeb28e..11853870091d 100644 --- a/frontend/src/app/core/global_search/input/global-search-input.component.ts +++ b/frontend/src/app/core/global_search/input/global-search-input.component.ts @@ -417,28 +417,28 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy { this.refreshFilteredResults(); } - public selectLastUpdatedFilter(filter:LastUpdatedFilter):void { - this.selectedLastUpdatedFilter = filter; + public selectLastUpdatedFilter(filter:LastUpdatedFilter|Event):void { + this.selectedLastUpdatedFilter = this.filterValue(filter); this.refreshFilteredResults(); } - public selectWorkPackageStatusFilter(filter:WorkPackageStatusFilter):void { - this.selectedWorkPackageStatusFilter = filter; + public selectWorkPackageStatusFilter(filter:WorkPackageStatusFilter|Event):void { + this.selectedWorkPackageStatusFilter = this.filterValue(filter); this.refreshFilteredResults(); } - public selectWorkPackageInvolvementFilter(filter:WorkPackageInvolvementFilter):void { - this.selectedWorkPackageInvolvementFilter = filter; + public selectWorkPackageInvolvementFilter(filter:WorkPackageInvolvementFilter|Event):void { + this.selectedWorkPackageInvolvementFilter = this.filterValue(filter); this.refreshFilteredResults(); } - public selectMeetingTimeFilter(filter:MeetingTimeFilter):void { - this.selectedMeetingTimeFilter = filter; + public selectMeetingTimeFilter(filter:MeetingTimeFilter|Event):void { + this.selectedMeetingTimeFilter = this.filterValue(filter); this.refreshFilteredResults(); } - public selectMeetingInvolvementFilter(filter:MeetingInvolvementFilter):void { - this.selectedMeetingInvolvementFilter = filter; + public selectMeetingInvolvementFilter(filter:MeetingInvolvementFilter|Event):void { + this.selectedMeetingInvolvementFilter = this.filterValue(filter); this.refreshFilteredResults(); } @@ -659,6 +659,12 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy { }); } + private filterValue(filter:T|Event):T { + return (filter instanceof Event + ? (filter.target as HTMLSelectElement).value + : filter) as T; + } + private workPackageFilters():ApiV3FilterBuilder { const filters = new ApiV3FilterBuilder(); if (this.activeResultType !== 'work_packages') { From d62caddb14643934d93cb19e1e441c9f6253fa63 Mon Sep 17 00:00:00 2001 From: Niels Lindenthal Date: Sun, 6 Sep 2026 12:09:40 +0200 Subject: [PATCH 3/6] Use Primer select panels for global search filters --- .../input/global-search-input.component.html | 120 ++++---------- .../input/global-search-input.component.sass | 15 +- .../input/global-search-input.component.ts | 12 +- .../global-search-quick-filter.component.html | 150 ++++++++++++++++++ .../global-search-quick-filter.component.ts | 125 +++++++++++++++ .../openproject-global-search.module.ts | 2 + 6 files changed, 319 insertions(+), 105 deletions(-) create mode 100644 frontend/src/app/core/global_search/input/global-search-quick-filter.component.html create mode 100644 frontend/src/app/core/global_search/input/global-search-quick-filter.component.ts diff --git a/frontend/src/app/core/global_search/input/global-search-input.component.html b/frontend/src/app/core/global_search/input/global-search-input.component.html index 8879d0fd5358..28e7ad1f228d 100644 --- a/frontend/src/app/core/global_search/input/global-search-input.component.html +++ b/frontend/src/app/core/global_search/input/global-search-input.component.html @@ -92,94 +92,44 @@
- + @if (activeResultType === 'work_packages') { - - + + } @if (activeResultType === 'meetings') { - - + + }
diff --git a/frontend/src/app/core/global_search/input/global-search-input.component.sass b/frontend/src/app/core/global_search/input/global-search-input.component.sass index 5545a2d76514..1d21dd88d46f 100644 --- a/frontend/src/app/core/global_search/input/global-search-input.component.sass +++ b/frontend/src/app/core/global_search/input/global-search-input.component.sass @@ -147,7 +147,7 @@ $search-input-height: 30px .global-search--quick-filters display: flex - align-items: flex-end + align-items: center flex-wrap: wrap gap: var(--base-size-8) margin: var(--base-size-8) -0.75rem -0.5rem @@ -156,19 +156,6 @@ $search-input-height: 30px border-bottom: 1px solid var(--borderColor-default) text-transform: none - .global-search--quick-filter-field - display: flex - flex-direction: column - gap: var(--base-size-4) - - .FormControl-select-wrap - min-width: 150px - - .global-search--quick-filter-label - color: var(--fgColor-muted) - font-size: var(--text-body-size-small) - font-weight: var(--base-text-weight-semibold) - .global-search--result-card, .global-search--result-card:hover display: flex diff --git a/frontend/src/app/core/global_search/input/global-search-input.component.ts b/frontend/src/app/core/global_search/input/global-search-input.component.ts index 11853870091d..d49d5cbfc72c 100644 --- a/frontend/src/app/core/global_search/input/global-search-input.component.ts +++ b/frontend/src/app/core/global_search/input/global-search-input.component.ts @@ -417,27 +417,27 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy { this.refreshFilteredResults(); } - public selectLastUpdatedFilter(filter:LastUpdatedFilter|Event):void { + public selectLastUpdatedFilter(filter:string|Event):void { this.selectedLastUpdatedFilter = this.filterValue(filter); this.refreshFilteredResults(); } - public selectWorkPackageStatusFilter(filter:WorkPackageStatusFilter|Event):void { + public selectWorkPackageStatusFilter(filter:string|Event):void { this.selectedWorkPackageStatusFilter = this.filterValue(filter); this.refreshFilteredResults(); } - public selectWorkPackageInvolvementFilter(filter:WorkPackageInvolvementFilter|Event):void { + public selectWorkPackageInvolvementFilter(filter:string|Event):void { this.selectedWorkPackageInvolvementFilter = this.filterValue(filter); this.refreshFilteredResults(); } - public selectMeetingTimeFilter(filter:MeetingTimeFilter|Event):void { + public selectMeetingTimeFilter(filter:string|Event):void { this.selectedMeetingTimeFilter = this.filterValue(filter); this.refreshFilteredResults(); } - public selectMeetingInvolvementFilter(filter:MeetingInvolvementFilter|Event):void { + public selectMeetingInvolvementFilter(filter:string|Event):void { this.selectedMeetingInvolvementFilter = this.filterValue(filter); this.refreshFilteredResults(); } @@ -659,7 +659,7 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy { }); } - private filterValue(filter:T|Event):T { + private filterValue(filter:string|Event):T { return (filter instanceof Event ? (filter.target as HTMLSelectElement).value : filter) as T; diff --git a/frontend/src/app/core/global_search/input/global-search-quick-filter.component.html b/frontend/src/app/core/global_search/input/global-search-quick-filter.component.html new file mode 100644 index 000000000000..61b61e32fc03 --- /dev/null +++ b/frontend/src/app/core/global_search/input/global-search-quick-filter.component.html @@ -0,0 +1,150 @@ + + + + + +
+
+
+

{{ label() }}

+
+
+ +
+
+ +
+ + + + +
+ + + + +
+
+
+
+
+ +
+ + +
+
+ +
+
    + @for (option of options(); track option) { +
  • + +
  • + } +
+
+
+
+ +
+
+
+ + +
+
+
diff --git a/frontend/src/app/core/global_search/input/global-search-quick-filter.component.ts b/frontend/src/app/core/global_search/input/global-search-quick-filter.component.ts new file mode 100644 index 000000000000..948ddda079c6 --- /dev/null +++ b/frontend/src/app/core/global_search/input/global-search-quick-filter.component.ts @@ -0,0 +1,125 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { + ChangeDetectionStrategy, + Component, + CUSTOM_ELEMENTS_SCHEMA, + ElementRef, + ViewChild, + effect, + inject, + input, + output, + signal, +} from '@angular/core'; +import type { SelectPanelElement } from '@primer/view-components/app/components/primer/alpha/select_panel_element'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; +import { DynamicIconDirective } from 'core-app/shared/components/primer/dynamic-icon.directive'; +import { generateId } from 'core-app/shared/helpers/dom-helpers'; + +interface SelectPanelItemActivatedEvent { + value:string; +} + +@Component({ + selector: 'op-global-search-quick-filter', + templateUrl: './global-search-quick-filter.component.html', + imports: [DynamicIconDirective], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class GlobalSearchQuickFilterComponent { + private readonly I18n = inject(I18nService); + + @ViewChild('selectPanel', { static: true }) selectPanel:ElementRef; + + readonly label = input.required(); + readonly options = input.required(); + readonly labels = input.required>(); + readonly selectedValue = input.required(); + readonly valueChange = output(); + + readonly panelId = generateId('global-search-quick-filter'); + readonly pendingValue = signal(''); + readonly filterLabel = this.I18n.t('js.toolbar.filter'); + readonly applyLabel = this.I18n.t('js.modals.button_apply'); + readonly closeLabel = this.I18n.t('js.button_close'); + readonly noResultsLabel = this.I18n.t('js.label_no_data'); + + constructor() { + effect(() => this.pendingValue.set(this.selectedValue())); + } + + get buttonId():string { + return `${this.panelId}-button`; + } + + get dialogId():string { + return `${this.panelId}-dialog`; + } + + get titleId():string { + return `${this.dialogId}-title`; + } + + get filterId():string { + return `${this.panelId}-filter`; + } + + get bodyId():string { + return `${this.panelId}-body`; + } + + get listId():string { + return `${this.panelId}-list`; + } + + onItemActivated(event:Event):void { + const value = (event as CustomEvent).detail.value; + this.pendingValue.set(value); + this.synchronizeSelection(value); + } + + resetSelection():void { + const value = this.selectedValue(); + this.pendingValue.set(value); + this.synchronizeSelection(value); + } + + apply():void { + this.valueChange.emit(this.pendingValue()); + this.selectPanel.nativeElement.hide(); + } + + private synchronizeSelection(value:string):void { + this.selectPanel.nativeElement + .querySelectorAll('[role="option"]') + .forEach((item) => item.setAttribute('aria-selected', String(item.dataset.value === value))); + } +} diff --git a/frontend/src/app/core/global_search/openproject-global-search.module.ts b/frontend/src/app/core/global_search/openproject-global-search.module.ts index b27f2f97fd18..4e9ff40bacaf 100644 --- a/frontend/src/app/core/global_search/openproject-global-search.module.ts +++ b/frontend/src/app/core/global_search/openproject-global-search.module.ts @@ -36,6 +36,7 @@ import { import { OpSharedModule } from 'core-app/shared/shared.module'; import { RecentItemsService } from 'core-app/core/recent-items.service'; import { DynamicIconDirective } from 'core-app/shared/components/primer/dynamic-icon.directive'; +import { GlobalSearchQuickFilterComponent } from 'core-app/core/global_search/input/global-search-quick-filter.component'; @NgModule({ imports: [ @@ -43,6 +44,7 @@ import { DynamicIconDirective } from 'core-app/shared/components/primer/dynamic- OpenprojectWorkPackagesModule, OpenprojectAutocompleterModule, DynamicIconDirective, + GlobalSearchQuickFilterComponent, ], providers: [ GlobalSearchService, From 09b65186f6a1ed07dba7a3b706c506496202a43a Mon Sep 17 00:00:00 2001 From: Niels Lindenthal Date: Sat, 24 Oct 2026 13:55:18 +0200 Subject: [PATCH 4/6] Fix wiki update filters and refresh search copyright Register the updated-at filter used by global search and cover its combination with search and visibility in the wiki collection API. Document updatedAt and regenerate the new filter component copyright header. --- .../apiv3/components/schemas/wiki_page_model.yml | 6 ++++++ docs/api/apiv3/tags/wiki_pages.yml | 1 + .../global-search-quick-filter.component.ts | 3 +-- modules/wikis/lib/open_project/wikis/engine.rb | 1 + spec/requests/api/v3/wiki_pages_resource_spec.rb | 16 +++++++++++++++- 5 files changed, 24 insertions(+), 3 deletions(-) diff --git a/docs/api/apiv3/components/schemas/wiki_page_model.yml b/docs/api/apiv3/components/schemas/wiki_page_model.yml index d294f1e0794e..7272909203f7 100644 --- a/docs/api/apiv3/components/schemas/wiki_page_model.yml +++ b/docs/api/apiv3/components/schemas/wiki_page_model.yml @@ -12,6 +12,11 @@ properties: title: type: string description: The wiki page's title + updatedAt: + type: string + format: date-time + description: Time of the most recent change to the wiki page + readOnly: true _links: type: object properties: @@ -34,6 +39,7 @@ example: _type: WikiPage id: 72 title: A wiki page with a name + updatedAt: '2026-09-05T12:00:00Z' _embedded: project: _type: Project... diff --git a/docs/api/apiv3/tags/wiki_pages.yml b/docs/api/apiv3/tags/wiki_pages.yml index 1f02001ac1ff..9e97db08184c 100644 --- a/docs/api/apiv3/tags/wiki_pages.yml +++ b/docs/api/apiv3/tags/wiki_pages.yml @@ -23,4 +23,5 @@ description: |- | :--------------: | ------------------------------------------- | ----------- | ------------------------------------ | -------------------- | | id | Identifier of this wiki page | Integer | x > 0 | READ | | title | The wiki page's title | String | not null | READ | + | updatedAt | Time of the most recent change | DateTime | not null | READ | name: Wiki Pages diff --git a/frontend/src/app/core/global_search/input/global-search-quick-filter.component.ts b/frontend/src/app/core/global_search/input/global-search-quick-filter.component.ts index 948ddda079c6..f86f250ac4ba 100644 --- a/frontend/src/app/core/global_search/input/global-search-quick-filter.component.ts +++ b/frontend/src/app/core/global_search/input/global-search-quick-filter.component.ts @@ -20,8 +20,7 @@ // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License -// along with this program; if not, write to the Free Software -// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// along with this program. If not, see . // // See COPYRIGHT and LICENSE files for more details. //++ diff --git a/modules/wikis/lib/open_project/wikis/engine.rb b/modules/wikis/lib/open_project/wikis/engine.rb index 5b2c5a8bfbba..8a55bb41cd6e 100644 --- a/modules/wikis/lib/open_project/wikis/engine.rb +++ b/modules/wikis/lib/open_project/wikis/engine.rb @@ -83,6 +83,7 @@ class Engine < ::Rails::Engine ::Queries::Register.register(::Queries::Wikis::WikiPages::WikiPageQuery) do filter ::Queries::Wikis::WikiPages::Filter::NameFilter filter ::Queries::Wikis::WikiPages::Filter::SearchFilter + filter ::Queries::Wikis::WikiPages::Filter::UpdatedAtFilter end end diff --git a/spec/requests/api/v3/wiki_pages_resource_spec.rb b/spec/requests/api/v3/wiki_pages_resource_spec.rb index 475fe6e7894e..54315a8e0728 100644 --- a/spec/requests/api/v3/wiki_pages_resource_spec.rb +++ b/spec/requests/api/v3/wiki_pages_resource_spec.rb @@ -60,9 +60,10 @@ let!(:matching_page) { create(:wiki_page, wiki:, title: "Release plan", text: "Launch checklist") } let!(:non_matching_page) { create(:wiki_page, wiki:, title: "Retrospective", text: "Team notes") } let!(:invisible_page) { create(:wiki_page, wiki: other_wiki, title: "Release plan") } + let(:filters) { [{ search: { operator: "**", values: [search_term] } }] } before do - get path, filters: [{ search: { operator: "**", values: [search_term] } }].to_json + get path, filters: filters.to_json end context "when the title matches" do @@ -81,6 +82,19 @@ expect(JSON.parse(subject.body).dig("_embedded", "elements").pluck("id")).to eq([matching_page.id]) end end + + context "when filtering by the last update" do + let!(:non_matching_page) { create(:wiki_page, wiki:, title: "Release archive", updated_at: 3.days.ago) } + let(:search_term) { "release" } + let(:filters) do + super() + [{ updatedAt: { operator: "<>d", values: [2.days.ago.iso8601, ""] } }] + end + + it "returns only recently updated matching visible wiki pages" do + expect(subject).to have_http_status(:ok) + expect(JSON.parse(subject.body).dig("_embedded", "elements").pluck("id")).to eq([matching_page.id]) + end + end end describe "GET /api/v3/wiki_pages/:id" do From 28e31afadb45c781a7789581ae88252e6797ea22 Mon Sep 17 00:00:00 2001 From: Niels Lindenthal Date: Sat, 24 Oct 2026 14:03:34 +0200 Subject: [PATCH 5/6] Load date property support for wiki page responses Include the date property decorator required by updatedAt so eager loading succeeds. Cover timestamp serialization in the wiki page API request specs. --- lib/api/v3/wiki_pages/wiki_page_representer.rb | 1 + spec/requests/api/v3/wiki_pages_resource_spec.rb | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/lib/api/v3/wiki_pages/wiki_page_representer.rb b/lib/api/v3/wiki_pages/wiki_page_representer.rb index fc7d033fa7c1..b2f3ca30a5b3 100644 --- a/lib/api/v3/wiki_pages/wiki_page_representer.rb +++ b/lib/api/v3/wiki_pages/wiki_page_representer.rb @@ -33,6 +33,7 @@ module WikiPages class WikiPageRepresenter < ::API::Decorators::Single include API::Decorators::LinkedResource include API::V3::Workspaces::LinkedResource + include API::Decorators::DateProperty include API::Caching::CachedRepresenter include ::API::V3::Attachments::AttachableRepresenterMixin diff --git a/spec/requests/api/v3/wiki_pages_resource_spec.rb b/spec/requests/api/v3/wiki_pages_resource_spec.rb index 54315a8e0728..3477e5218da7 100644 --- a/spec/requests/api/v3/wiki_pages_resource_spec.rb +++ b/spec/requests/api/v3/wiki_pages_resource_spec.rb @@ -119,6 +119,11 @@ .at_path("id") end + it "returns the last update as an ISO 8601 timestamp" do + expect(Time.iso8601(JSON.parse(subject.body).fetch("updatedAt"))) + .to be_within(1.second).of(wiki_page.updated_at) + end + context "when lacking permissions" do let(:permissions) { [] } From 3b791d64c194823d724e8e8005cbfeb23ec5d4ef Mon Sep 17 00:00:00 2001 From: Niels Lindenthal Date: Sat, 24 Oct 2026 14:58:09 +0200 Subject: [PATCH 6/6] Fix global search navigation and feature coverage Preserve the selected project scope and resource type when submitting searches. Register the quick-filter icons so oversized empty SVGs cannot intercept tab clicks. Update feature helpers to use the new scope selector and resource tabs, and cover search state in unit tests. --- .../global-search-input.component.spec.ts | 33 +++++++++++++++++++ .../input/global-search-input.component.ts | 9 ++++- .../components/primer/dynamic-icon-map.ts | 4 +++ .../spec/features/meetings_search_spec.rb | 26 +++++---------- spec/features/search/search_spec.rb | 7 ++-- spec/support/components/global_search.rb | 25 +++++++------- 6 files changed, 71 insertions(+), 33 deletions(-) diff --git a/frontend/src/app/core/global_search/input/global-search-input.component.spec.ts b/frontend/src/app/core/global_search/input/global-search-input.component.spec.ts index 75c36cf891be..b60909007eca 100644 --- a/frontend/src/app/core/global_search/input/global-search-input.component.spec.ts +++ b/frontend/src/app/core/global_search/input/global-search-input.component.spec.ts @@ -27,6 +27,7 @@ import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { HalSource } from 'core-app/features/hal/interfaces'; +import { of } from 'rxjs'; import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; import { MeetingResource } from 'core-app/features/hal/resources/meeting-resource'; import { WikiPageResource } from 'core-app/features/hal/resources/wiki-page-resource'; @@ -34,6 +35,7 @@ import { GlobalSearchInputComponent, lastUpdatedRange, searchResultMatchesType, + searchResultTypeFromFilter, } from './global-search-input.component'; describe('lastUpdatedRange', () => { @@ -94,6 +96,19 @@ describe('searchResultMatchesType', () => { }); }); +describe('searchResultTypeFromFilter', () => { + it('preserves the selected resource type from the search results page', () => { + expect(searchResultTypeFromFilter('work_packages')).toBe('work_packages'); + expect(searchResultTypeFromFilter('meetings')).toBe('meetings'); + expect(searchResultTypeFromFilter('wiki_pages')).toBe('wiki_pages'); + }); + + it('uses all resource types without a supported search filter', () => { + expect(searchResultTypeFromFilter(null)).toBe('all'); + expect(searchResultTypeFromFilter('projects')).toBe('all'); + }); +}); + // followItem is verified through the prototype against a stand-in context, avoiding // a real component instance whose many injected dependencies this branch never uses. describe('GlobalSearchInputComponent#followItem', () => { @@ -205,3 +220,21 @@ describe('GlobalSearchInputComponent#onFocusOut', () => { expect(setOpenCalled).toBe(false); }); }); + +describe('GlobalSearchInputComponent#onEnterBeforeResultsLoaded', () => { + it('submits in the explicitly selected project scope', () => { + const submittedScopes:string[] = []; + const context = { + markable$: of(false), + selectedItem: undefined, + selectedProjectScope: 'all_projects', + searchInScope: (scope:string):void => { submittedScopes.push(scope); }, + }; + + GlobalSearchInputComponent.prototype.onEnterBeforeResultsLoaded.call( + context as unknown as GlobalSearchInputComponent, + ); + + expect(submittedScopes).toEqual(['all_projects']); + }); +}); diff --git a/frontend/src/app/core/global_search/input/global-search-input.component.ts b/frontend/src/app/core/global_search/input/global-search-input.component.ts index d49d5cbfc72c..7c9d1d438ab3 100644 --- a/frontend/src/app/core/global_search/input/global-search-input.component.ts +++ b/frontend/src/app/core/global_search/input/global-search-input.component.ts @@ -104,6 +104,12 @@ export function searchResultMatchesType(resultType:SearchResultType, item:Global || (resultType === 'wiki_pages' && item instanceof WikiPageResource); } +export function searchResultTypeFromFilter(filter:string|null):SearchResultType { + return filter === 'work_packages' || filter === 'meetings' || filter === 'wiki_pages' + ? filter + : 'all'; +} + type GlobalSearchItem = GlobalSearchResult; @Component({ @@ -244,6 +250,7 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy { constructor() { populateInputsFromDataset(this); + this.activeResultType = searchResultTypeFromFilter(new URLSearchParams(window.location.search).get('filter')); this.selectedProjectScope = this.currentProjectService.path ? this.currentScope : 'all_projects'; @@ -373,7 +380,7 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy { if (this.selectedItem) { this.followSelectedItem(); } else { - this.searchInScope(this.currentScope); + this.searchInScope(this.selectedProjectScope); } }); } diff --git a/frontend/src/app/shared/components/primer/dynamic-icon-map.ts b/frontend/src/app/shared/components/primer/dynamic-icon-map.ts index 144c8807c963..6636f6659c98 100644 --- a/frontend/src/app/shared/components/primer/dynamic-icon-map.ts +++ b/frontend/src/app/shared/components/primer/dynamic-icon-map.ts @@ -31,8 +31,10 @@ import { commentDiscussionIconData, graphIconData, opViewListIconData, + searchIconData, starIconData, SVGData, + triangleDownIconData, xIconData, } from '@openproject/octicons-angular'; @@ -44,5 +46,7 @@ export const ICON_MAP:Record = { book: bookIconData, 'comment-discussion': commentDiscussionIconData, 'op-view-list': opViewListIconData, + search: searchIconData, + 'triangle-down': triangleDownIconData, // TODO add more icons }; diff --git a/modules/meeting/spec/features/meetings_search_spec.rb b/modules/meeting/spec/features/meetings_search_spec.rb index c821b9e7440f..ddf30efb337f 100644 --- a/modules/meeting/spec/features/meetings_search_spec.rb +++ b/modules/meeting/spec/features/meetings_search_spec.rb @@ -46,32 +46,26 @@ visit project_path(project) end - context "global search" do + context "when using global search" do it "works with a title" do - select_autocomplete(page.find(".top-menu-search--input"), - query: "Meeting", - select_text: "In this project ↵", - wait_dropdown_open: true) + global_search.search("Meeting") + global_search.submit_in_current_project global_search.open_tab :meetings expect(page.find_by_id("search-results")).to have_text(meeting.title) end it "works with an agenda item title" do - select_autocomplete(page.find(".top-menu-search--input"), - query: agenda_item.title, - select_text: "In this project ↵", - wait_dropdown_open: true) + global_search.search(agenda_item.title) + global_search.submit_in_current_project global_search.open_tab :meetings expect(page.find_by_id("search-results")).to have_text(meeting.title) end it "works with an agenda item notes" do - select_autocomplete(page.find(".top-menu-search--input"), - query: agenda_item.notes, - select_text: "In this project ↵", - wait_dropdown_open: true) + global_search.search(agenda_item.notes) + global_search.submit_in_current_project global_search.open_tab :meetings expect(page.find_by_id("search-results")).to have_text(meeting.title) @@ -94,10 +88,8 @@ end it "opens the meeting in its own project" do - select_autocomplete(page.find(".top-menu-search--input"), - query: agenda_item.notes, - select_text: I18n.t("js.global_search.current_project_and_all_descendants"), - wait_dropdown_open: true) + global_search.search(agenda_item.notes) + global_search.submit_in_project_and_subproject_scope global_search.open_tab :meetings page.find_by_id("search-results").click_link(meeting.title) diff --git a/spec/features/search/search_spec.rb b/spec/features/search/search_spec.rb index cfe3551314b5..f8374f31751a 100644 --- a/spec/features/search/search_spec.rb +++ b/spec/features/search/search_spec.rb @@ -169,10 +169,9 @@ def search_in_global_scope(query) global_search.expect_work_package_marked(search_target) - # Expect to have 3 project scope selecting menu entries - global_search.expect_scope("In this project ↵") - global_search.expect_scope("In this project + subprojects ↵") - global_search.expect_scope("In all projects ↵") + global_search.expect_scope("In this project") + global_search.expect_scope("In this project + subprojects") + global_search.expect_scope("In all projects") # Selection project scope 'In all projects' redirects away from current project. global_search.submit_in_global_scope diff --git a/spec/support/components/global_search.rb b/spec/support/components/global_search.rb index c29d6406c3a7..61b846544329 100644 --- a/spec/support/components/global_search.rb +++ b/spec/support/components/global_search.rb @@ -102,18 +102,16 @@ def submit_in_global_scope end def expect_global_scope_marked - expect(page) - .to have_css('.global-search--project-scope[title="all_projects"]', wait: 10) + expect_selected_scope("all_projects") end def expect_in_project_and_subproject_scope_marked - expect(page) - .to have_css('.global-search--project-scope[title="current_project_and_all_descendants"]', wait: 10) + expect_selected_scope("current_project_and_all_descendants") end def expect_scope(text) expect(page) - .to have_css(".global-search--project-scope", text:, wait: 10) + .to have_css(".global-search--project-scope-select option", text:, visible: :all, wait: 10) end def expect_work_package_marked(work_package) @@ -136,12 +134,17 @@ def click_work_package(work_package) end def submit_in_scope(scope) - scope_selector = ".global-search--project-scope[title=\"#{scope}\"]" + page.find(".global-search--tabs") + .click_button(I18n.t("js.label_work_package_plural"), exact: true) + page.find(".global-search--project-scope-select") + .find("option[value='#{scope}']").select_option + page.find_by_id("top-menu-search-button").click + SeleniumHubWaiter.wait + expect(page).to have_no_css(".global-search .ng-dropdown-panel", visible: :visible) + end - page.document.synchronize(20) do - page.find(scope_selector, wait: 0).ancestor("a").click if page.has_css?(scope_selector, wait: 0) - raise Capybara::ElementNotFound if page.has_css?(".global-search .ng-dropdown-panel", visible: :visible, wait: 0) - end + def expect_selected_scope(scope) + expect(page.find(".global-search--project-scope-select").value).to eq(scope) end def find_work_package(work_package) @@ -149,7 +152,7 @@ def find_work_package(work_package) end def find_option(text) - find(".global-search .op-autocompleter--wp-subject", text:, wait: 10) + find(".global-search .global-search--result-title", text:, wait: 10) end def cancel