From 0502584bec85cf9b1ffd1a89216b492e5705d2cc Mon Sep 17 00:00:00 2001 From: Niels Lindenthal Date: Thu, 3 Sep 2026 13:28:45 +0200 Subject: [PATCH] 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 | 155 +++++-- .../input/global-search-input.component.sass | 118 ++++- .../global-search-input.component.spec.ts | 121 ++++- .../input/global-search-input.component.ts | 434 +++++++++++++++--- .../openproject-global-search.module.ts | 2 + .../services/global-search.service.ts | 13 +- .../hal/resources/meeting-resource.ts | 4 +- .../hal/resources/wiki-page-resource.ts | 13 +- .../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 | 3 + .../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, 1265 insertions(+), 133 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 dc2a37bfd2c2..4d71d9c53770 100644 --- a/config/locales/js-en.yml +++ b/config/locales/js-en.yml @@ -375,7 +375,23 @@ en: direct_hit_available: "Work package with exact ID found. Press Enter to open it." items_available: "%{count} items available" 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" + wiki_pages: "Wiki pages" + quick_filters: + accountable: "Accountable" + any_time: "Any time" + attended: "Attended" + closed: "Closed" + involvement: "Involvement" + invited: "Invited" + meeting_date: "Meeting date" + open: "Open" + past: "Past" + past_7_days: "Past 7 days" + past_30_days: "Past 30 days" + past_year: "Past year" + upcoming: "Upcoming" + yesterday: "Yesterday" title: all_projects: "all projects" project_and_subprojects: "and all subprojects" 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 b8238b0d92f0..51913c3c747c 100644 --- a/docs/api/apiv3/openapi-spec.yml +++ b/docs/api/apiv3/openapi-spec.yml @@ -569,6 +569,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": @@ -1160,6 +1162,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 7adc5995950a..91cda70aba5a 100644 --- a/frontend/src/app/core/apiv3/api-v3.service.ts +++ b/frontend/src/app/core/apiv3/api-v3.service.ts @@ -127,6 +127,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 009e1fdcd68a..c38250c33211 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 @@ -114,8 +114,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 a1568f29b670..8a9c87520ec6 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 @@ -46,7 +46,7 @@ [searchFn]="customSearchFn" [virtualScroll]="false" (focus)="onFocus()" - (blur)="onFocusOut()" + (blur)="onFocusOut($event)" (search)="search($event)" (close)="onClose()" (change)="followItem($event)" @@ -62,29 +62,122 @@ @if (!searchTerm && (hasRecentItems$ | async)) {
{{text.recently_viewed}}
} + @if (searchTerm) { +
+
+ @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 }} ↵
-
-
- } @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 4257977ec617..59129ba11243 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,77 @@ import { HalResource } from 'core-app/features/hal/resources/hal-resource'; 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('includes today in the rolling day presets', () => { + expect(lastUpdatedRange('past_7_days', now)?.[0]).toBe( + new Date(2026, 7, 30).toISOString(), + ); + expect(lastUpdatedRange('past_30_days', now)?.[0]).toBe( + new Date(2026, 7, 7).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 +106,6 @@ describe('GlobalSearchInputComponent#followItem', () => { beforeEach(() => { wpPathArgs = []; - searchInScopeArgs = []; context = { wpPath: (id:string):string => { wpPathArgs.push(id); @@ -51,9 +113,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 +159,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 010d027feb66..3140754fe7fb 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 @@ -27,8 +27,8 @@ //++ 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, tap } from 'rxjs/operators'; +import { BehaviorSubject, forkJoin, Observable, of } from 'rxjs'; +import { catchError, first, map, switchMap } from 'rxjs/operators'; import { GlobalSearchService } from 'core-app/core/global_search/services/global-search.service'; import { isClickedWithModifier } from 'core-app/shared/helpers/link-handling/link-handling'; import { @@ -55,27 +55,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()]; + } + + const start = new Date(startOfToday); + if (filter === 'past_year') { + start.setFullYear(start.getFullYear() - 1); + } else { + start.setDate(start.getDate() - (filter === 'past_7_days' ? 6 : 29)); + } -interface SearchOptionItem { - projectScope:string; - text:string; + 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, @@ -101,6 +134,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; @@ -112,6 +146,10 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy { private _searchTermInitialized = false; + private currentSearchResults:GlobalSearchResult[] = []; + private currentResultQuery = ''; + private projectScopeInteraction = false; + // Computed placeholder that changes based on expanded state public get effectivePlaceholder():string { return this.expanded @@ -127,7 +165,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: [], @@ -139,7 +200,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 = ''; @@ -151,16 +212,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.label_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 { @@ -205,7 +293,7 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy { } else if (this.searchTerm?.length === 0) { this.ngSelectComponent.ngSelectInstance.focus(); } else { - this.submitNonEmptySearch(''); + this.searchInScope(this.selectedProjectScope); } } } @@ -235,7 +323,7 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy { return Highlighting.inlineClass(property, id); } - public search(_$event:SearchResultItems):void { + public search(_$event:unknown):void { this.currentValue = this.searchTerm; } @@ -250,7 +338,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); @@ -287,12 +383,14 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy { return Highlighting.inlineClass('status', statusId); } - public followItem(item:WorkPackageResource|SearchOptionItem|undefined):void { + 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.projectScope); + } else if (this.isMeeting(item)) { + window.location.href = this.pathHelperService.meetingPath(item.id!); } } @@ -302,12 +400,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()); + } + + 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; } - private autocompleteWorkPackages():Observable<(WorkPackageResource|SearchOptionItem)[]> { + 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 ?? ''; @@ -327,12 +541,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() { @@ -378,48 +619,107 @@ 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 { + if (!this.currentValue.length) { + this.ngSelectComponent.updateItems(this.searchResultsToOptions( + this.currentSearchResults, + this.currentResultQuery, + )); + return; + } + + this.autocompleteGlobalSearch().pipe(first()).subscribe((items) => { + this.ngSelectComponent.updateItems(items); + 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); } /* @@ -456,7 +756,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'); @@ -478,14 +778,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 6eccbfba1e67..c529c0a9ac7e 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,12 +36,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 0d3afb9c34e5..5a330ed8a749 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 @@ -40,9 +40,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}`; } @@ -54,14 +54,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 4ee82f66b32e..5bd617d2a796 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 { HalResource } 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(attachment:HalResource):Promise; @@ -35,7 +36,8 @@ interface MeetingResourceLinks { class MeetingBaseResource extends HalResource { title:string; - project:HalResource; + project:ProjectResource; + updatedAt:Date; public $links:MeetingResourceLinks; } 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 f112f1e1525e..c64f7f263fc6 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 { HalResource } 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(attachment:HalResource):Promise; + addAttachment(attachment:HalResource):Promise; } class WikiPageBaseResource extends HalResource { + title:string; + project:ProjectResource; + updatedAt:Date; + showWikiPage:HalResource; public $links:WikiPageResourceLinks; + + 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 0f34c19c7397..3b271f37d72b 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 @@ -334,6 +334,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 9a473b96c5dd..a22151d57e32 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 @@ -28,7 +28,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 8eb3d9875a49..ca296cf8bc67 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 @@ -34,10 +36,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 @@ -53,7 +57,7 @@ def mount def parse(request) ParamsToQueryService - .new(model, request.current_user) + .new(model, request.current_user, query_class:) .call(request.params) end @@ -71,6 +75,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 40cfed84493d..d1d2394fa179 100644 --- a/lib/api/v3/utilities/path_helper.rb +++ b/lib/api/v3/utilities/path_helper.rb @@ -658,6 +658,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 5d770dabb0ac..06fd35989ae6 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 @@ -37,10 +39,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 8b85f5ce34f9..5e3769d599e0 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 @@ -31,6 +33,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 136167f661f1..56caaf182a31 100644 --- a/modules/meeting/app/models/queries/meetings.rb +++ b/modules/meeting/app/models/queries/meetings.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -36,6 +37,8 @@ module Queries::Meetings filter Filters::AuthorFilter filter Filters::DatesIntervalFilter filter Filters::RecurringFilter + 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 c5bdea8c692a..f0fe042bc747 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 @@ -48,6 +48,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 e5edefe2bcdb..b1fd44d23c66 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 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 f678809691cf..a70a17c5a778 100644 --- a/spec/requests/api/v3/wiki_pages_resource_spec.rb +++ b/spec/requests/api/v3/wiki_pages_resource_spec.rb @@ -56,6 +56,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) }