From 5e089219d06b27a870aa5b3d76447a2c3353127a Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Wed, 28 Jun 2023 13:22:34 +0200 Subject: [PATCH] MOBILE-3371 search: Implement global search --- scripts/langindex.json | 4 + src/core/classes/site.ts | 2 + .../classes/global-search-results-source.ts | 145 +++++++++ .../search/components/components.module.ts | 3 - src/core/features/search/lang.json | 4 + .../pages/global-search/global-search.html | 46 +++ .../pages/global-search/global-search.ts | 96 ++++++ .../features/search/search-lazy.module.ts | 56 ++++ src/core/features/search/search.module.ts | 25 +- .../features/search/services/global-search.ts | 307 +++++++++++++++++- .../search/services/handlers/mainmenu.ts | 56 ++++ .../search/tests/behat/global-search.feature | 99 ++++++ src/core/services/utils/dom.ts | 22 ++ 13 files changed, 860 insertions(+), 5 deletions(-) create mode 100644 src/core/features/search/classes/global-search-results-source.ts create mode 100644 src/core/features/search/pages/global-search/global-search.html create mode 100644 src/core/features/search/pages/global-search/global-search.ts create mode 100644 src/core/features/search/search-lazy.module.ts create mode 100644 src/core/features/search/services/handlers/mainmenu.ts create mode 100644 src/core/features/search/tests/behat/global-search.feature diff --git a/scripts/langindex.json b/scripts/langindex.json index 9447432ca0b..14612751995 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -2299,6 +2299,10 @@ "core.scanqr": "local_moodlemobileapp", "core.scrollbackward": "local_moodlemobileapp", "core.scrollforward": "local_moodlemobileapp", + "core.search.empty": "local_moodlemobileapp", + "core.search.globalsearch": "search", + "core.search.noresults": "local_moodlemobileapp", + "core.search.noresultshelp": "local_moodlemobileapp", "core.search.resultby": "local_moodlemobileapp", "core.search": "moodle", "core.searching": "local_moodlemobileapp", diff --git a/src/core/classes/site.ts b/src/core/classes/site.ts index 0fe84afb1b6..418cecf1ee5 100644 --- a/src/core/classes/site.ts +++ b/src/core/classes/site.ts @@ -2746,6 +2746,8 @@ export const enum CoreSiteConfigSupportAvailability { */ export type CoreSiteConfig = Record & { supportavailability?: string; // String representation of CoreSiteConfigSupportAvailability. + searchbanner?: string; // Search banner text. + searchbannerenable?: string; // Whether search banner is enabled. }; /** diff --git a/src/core/features/search/classes/global-search-results-source.ts b/src/core/features/search/classes/global-search-results-source.ts new file mode 100644 index 00000000000..183f0c4af51 --- /dev/null +++ b/src/core/features/search/classes/global-search-results-source.ts @@ -0,0 +1,145 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + CoreSearchGlobalSearchResult, + CoreSearchGlobalSearch, + CORE_SEARCH_GLOBAL_SEARCH_PAGE_LENGTH, + CoreSearchGlobalSearchFilters, +} from '@features/search/services/global-search'; +import { CorePaginatedItemsManagerSource } from '@classes/items-management/paginated-items-manager-source'; + +/** + * Provides a collection of global search results. + */ +export class CoreSearchGlobalSearchResultsSource extends CorePaginatedItemsManagerSource { + + private query: string; + private filters: CoreSearchGlobalSearchFilters; + private pagesLoaded = 0; + private topResultsIds?: number[]; + + constructor(query: string, filters: CoreSearchGlobalSearchFilters) { + super(); + + this.query = query; + this.filters = filters; + } + + /** + * Check whether the source has an empty query. + * + * @returns Whether the source has an empty query. + */ + hasEmptyQuery(): boolean { + return !this.query || this.query.trim().length === 0; + } + + /** + * Get search query. + * + * @returns Search query. + */ + getQuery(): string { + return this.query; + } + + /** + * Get search filters. + * + * @returns Search filters. + */ + getFilters(): CoreSearchGlobalSearchFilters { + return this.filters; + } + + /** + * Set search query. + * + * @param query Search query. + */ + setQuery(query: string): void { + this.query = query; + + this.setDirty(true); + } + + /** + * Set search filters. + * + * @param filters Search filters. + */ + setFilters(filters: CoreSearchGlobalSearchFilters): void { + this.filters = filters; + + this.setDirty(true); + } + + /** + * @inheritdoc + */ + getPagesLoaded(): number { + return this.pagesLoaded; + } + + /** + * @inheritdoc + */ + async reload(): Promise { + this.pagesLoaded = 0; + + await super.reload(); + } + + /** + * Reset collection data. + */ + reset(): void { + this.pagesLoaded = 0; + delete this.topResultsIds; + + super.reset(); + } + + /** + * @inheritdoc + */ + protected async loadPageItems(page: number): Promise<{ items: CoreSearchGlobalSearchResult[]; hasMoreItems: boolean }> { + this.pagesLoaded++; + + const results: CoreSearchGlobalSearchResult[] = []; + + if (page === 0) { + const topResults = await CoreSearchGlobalSearch.getTopResults(this.query, this.filters); + + results.push(...topResults); + + this.topResultsIds = topResults.map(result => result.id); + } + + const pageResults = await CoreSearchGlobalSearch.getResults(this.query, this.filters, page); + + results.push(...pageResults.results.filter(result => !this.topResultsIds?.includes(result.id))); + + return { items: results, hasMoreItems: pageResults.canLoadMore }; + } + + /** + * @inheritdoc + */ + protected getPageLength(): number { + return CORE_SEARCH_GLOBAL_SEARCH_PAGE_LENGTH; + } + +} diff --git a/src/core/features/search/components/components.module.ts b/src/core/features/search/components/components.module.ts index 730dc349b0e..2c935312a1a 100644 --- a/src/core/features/search/components/components.module.ts +++ b/src/core/features/search/components/components.module.ts @@ -16,19 +16,16 @@ import { NgModule } from '@angular/core'; import { CoreSharedModule } from '@/core/shared.module'; import { CoreSearchBoxComponent } from './search-box/search-box'; -import { CoreSearchGlobalSearchResultComponent } from '@features/search/components/global-search-result/global-search-result'; @NgModule({ declarations: [ CoreSearchBoxComponent, - CoreSearchGlobalSearchResultComponent, ], imports: [ CoreSharedModule, ], exports: [ CoreSearchBoxComponent, - CoreSearchGlobalSearchResultComponent, ], }) export class CoreSearchComponentsModule {} diff --git a/src/core/features/search/lang.json b/src/core/features/search/lang.json index f242e9e973d..0350a4c0f65 100644 --- a/src/core/features/search/lang.json +++ b/src/core/features/search/lang.json @@ -1,3 +1,7 @@ { + "empty": "What are you searching for?", + "globalsearch": "Global search", + "noresults": "No results for \"{{$a}}\"", + "noresultshelp": "Check for typos or try using different keywords", "resultby": "By {{$a}}" } diff --git a/src/core/features/search/pages/global-search/global-search.html b/src/core/features/search/pages/global-search/global-search.html new file mode 100644 index 00000000000..2e1099bce95 --- /dev/null +++ b/src/core/features/search/pages/global-search/global-search.html @@ -0,0 +1,46 @@ + + + + + + +

{{ 'core.search.globalsearch' | translate }}

+
+ + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + +

{{ 'core.search.empty' | translate }}

+ +

{{ 'core.search.noresults' | translate: { $a: this.resultsSource.getQuery() } }}

+

{{ 'core.search.noresultshelp' | translate }}

+
+
+
+
diff --git a/src/core/features/search/pages/global-search/global-search.ts b/src/core/features/search/pages/global-search/global-search.ts new file mode 100644 index 00000000000..5d8aa967789 --- /dev/null +++ b/src/core/features/search/pages/global-search/global-search.ts @@ -0,0 +1,96 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit } from '@angular/core'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper'; +import { CoreSearchGlobalSearchResultsSource } from '@features/search/classes/global-search-results-source'; +import { CoreSites } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreSearchGlobalSearchResult, CoreSearchGlobalSearch } from '@features/search/services/global-search'; + +@Component({ + selector: 'page-core-search-global-search', + templateUrl: 'global-search.html', +}) +export class CoreSearchGlobalSearchPage implements OnInit { + + loadMoreError: string | null = null; + searchBanner: string | null = null; + resultsSource = new CoreSearchGlobalSearchResultsSource('', {}); + + /** + * @inheritdoc + */ + ngOnInit(): void { + const site = CoreSites.getRequiredCurrentSite(); + const searchBanner = site.config?.searchbanner?.trim() ?? ''; + + if (CoreUtils.isTrueOrOne(site.config?.searchbannerenable) && searchBanner.length > 0) { + this.searchBanner = searchBanner; + } + } + + /** + * Perform a new search. + * + * @param query Search query. + */ + async search(query: string): Promise { + this.resultsSource.setQuery(query); + + if (this.resultsSource.hasEmptyQuery()) { + return; + } + + await CoreDomUtils.showOperationModals('core.searching', true, async () => { + await this.resultsSource.reload(); + await CoreUtils.ignoreErrors( + CoreSearchGlobalSearch.logViewResults(this.resultsSource.getQuery(), this.resultsSource.getFilters()), + ); + }); + } + + /** + * Clear search results. + */ + clearSearch(): void { + this.loadMoreError = null; + } + + /** + * Visit a result's origin. + * + * @param result Result to visit. + */ + async visitResult(result: CoreSearchGlobalSearchResult): Promise { + await CoreContentLinksHelper.handleLink(result.url); + } + + /** + * Load more results. + * + * @param complete Notify completion. + */ + async loadMoreResults(complete: () => void ): Promise { + try { + await this.resultsSource?.load(); + } catch (error) { + this.loadMoreError = CoreDomUtils.getErrorMessage(error); + } finally { + complete(); + } + } + +} diff --git a/src/core/features/search/search-lazy.module.ts b/src/core/features/search/search-lazy.module.ts new file mode 100644 index 00000000000..61c70cce0e3 --- /dev/null +++ b/src/core/features/search/search-lazy.module.ts @@ -0,0 +1,56 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSharedModule } from '@/core/shared.module'; +import { NgModule, Injector } from '@angular/core'; +import { RouterModule, Routes, ROUTES } from '@angular/router'; +import { CoreSearchGlobalSearchPage } from './pages/global-search/global-search'; +import { CoreSearchComponentsModule } from '@features/search/components/components.module'; +import { CoreMainMenuComponentsModule } from '@features/mainmenu/components/components.module'; +import { buildTabMainRoutes } from '@features/mainmenu/mainmenu-tab-routing.module'; +import { CoreSearchGlobalSearchResultComponent } from '@features/search/components/global-search-result/global-search-result'; + +/** + * Build module routes. + * + * @param injector Injector. + * @returns Routes. + */ +function buildRoutes(injector: Injector): Routes { + return buildTabMainRoutes(injector, { + component: CoreSearchGlobalSearchPage, + }); +} + +@NgModule({ + imports: [ + CoreSharedModule, + CoreSearchComponentsModule, + CoreMainMenuComponentsModule, + ], + exports: [RouterModule], + declarations: [ + CoreSearchGlobalSearchPage, + CoreSearchGlobalSearchResultComponent, + ], + providers: [ + { + provide: ROUTES, + multi: true, + deps: [Injector], + useFactory: buildRoutes, + }, + ], +}) +export class CoreSearchLazyModule {} diff --git a/src/core/features/search/search.module.ts b/src/core/features/search/search.module.ts index b60effb9ccf..0c19dc2d1dd 100644 --- a/src/core/features/search/search.module.ts +++ b/src/core/features/search/search.module.ts @@ -12,7 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { NgModule, Type } from '@angular/core'; +import { APP_INITIALIZER, NgModule, Type } from '@angular/core'; +import { Routes } from '@angular/router'; +import { CoreMainMenuRoutingModule } from '@features/mainmenu/mainmenu-routing.module'; +import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module'; +import { CoreMainMenuDelegate } from '@features/mainmenu/services/mainmenu-delegate'; +import { CoreSearchGlobalSearchService } from '@features/search/services/global-search'; +import { CoreSearchMainMenuHandler, CORE_SEARCH_PAGE_NAME } from '@features/search/services/handlers/mainmenu'; import { CORE_SITE_SCHEMAS } from '@services/sites'; @@ -22,14 +28,31 @@ import { CoreSearchHistoryProvider } from './services/search-history.service'; export const CORE_SEARCH_SERVICES: Type[] = [ CoreSearchHistoryProvider, + CoreSearchGlobalSearchService, +]; + +const mainMenuChildrenRoutes: Routes = [ + { + path: CORE_SEARCH_PAGE_NAME, + loadChildren: () => import('./search-lazy.module').then(m => m.CoreSearchLazyModule), + }, ]; @NgModule({ imports: [ CoreSearchComponentsModule, + CoreMainMenuTabRoutingModule.forChild(mainMenuChildrenRoutes), + CoreMainMenuRoutingModule.forChild({ children: mainMenuChildrenRoutes }), ], providers: [ { provide: CORE_SITE_SCHEMAS, useValue: [SITE_SCHEMA], multi: true }, + { + provide: APP_INITIALIZER, + multi: true, + useValue() { + CoreMainMenuDelegate.registerHandler(CoreSearchMainMenuHandler.instance); + }, + }, ], }) export class CoreSearchModule {} diff --git a/src/core/features/search/services/global-search.ts b/src/core/features/search/services/global-search.ts index 2a87b2d2376..6269b21a873 100644 --- a/src/core/features/search/services/global-search.ts +++ b/src/core/features/search/services/global-search.ts @@ -12,8 +12,16 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { CoreCourseListItem } from '@features/courses/services/courses'; +import { Injectable } from '@angular/core'; +import { makeSingleton } from '@singletons'; +import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreWSExternalWarning } from '@services/ws'; +import { CoreCourseListItem, CoreCourses } from '@features/courses/services/courses'; import { CoreUserWithAvatar } from '@components/user-avatar/user-avatar'; +import { CoreUser } from '@features/user/services/user'; +import { CorePushNotifications } from '@features/pushnotifications/services/pushnotifications'; + +export const CORE_SEARCH_GLOBAL_SEARCH_PAGE_LENGTH = 10; export type CoreSearchGlobalSearchResult = { id: number; @@ -35,3 +43,300 @@ export type CoreSearchGlobalSearchResultModule = { name: string; iconurl: string; }; + +export type CoreSearchGlobalSearchSearchAreaCategory = { + id: string; + name: string; +}; + +export type CoreSearchGlobalSearchSearchArea = { + id: string; + name: string; + category: CoreSearchGlobalSearchSearchAreaCategory; +}; + +export interface CoreSearchGlobalSearchFilters { + searchAreaCategoryIds?: string[]; + courseIds?: number[]; +} + +/** + * Service to perform global searches. + */ +@Injectable({ providedIn: 'root' }) +export class CoreSearchGlobalSearchService { + + /** + * Get results. + * + * @param query Search query. + * @param filters Search filters. + * @param page Page. + * @returns Search results. + */ + async getResults( + query: string, + filters: CoreSearchGlobalSearchFilters, + page: number, + ): Promise<{ results: CoreSearchGlobalSearchResult[]; canLoadMore: boolean }> { + const site = CoreSites.getRequiredCurrentSite(); + const params: CoreSearchGetResultsWSParams = { + query, + page, + filters: await this.prepareWSFilters(filters), + }; + const preSets = CoreSites.getReadingStrategyPreSets(CoreSitesReadingStrategy.ONLY_NETWORK); + + const { totalcount, results } = await site.read('core_search_get_results', params, preSets); + + return { + results: await Promise.all((results ?? []).map(result => this.formatWSResult(result))), + canLoadMore: totalcount > (page + 1) * CORE_SEARCH_GLOBAL_SEARCH_PAGE_LENGTH, + }; + } + + /** + * Get top results. + * + * @param query Search query. + * @param filters Search filters. + * @returns Top search results. + */ + async getTopResults(query: string, filters: CoreSearchGlobalSearchFilters): Promise { + const site = CoreSites.getRequiredCurrentSite(); + const params: CoreSearchGetTopResultsWSParams = { + query, + filters: await this.prepareWSFilters(filters), + }; + const preSets = CoreSites.getReadingStrategyPreSets(CoreSitesReadingStrategy.ONLY_NETWORK); + + const { results } = await site.read('core_search_get_top_results', params, preSets); + + return await Promise.all((results ?? []).map(result => this.formatWSResult(result))); + } + + /** + * Get available search areas. + * + * @returns Search areas. + */ + async getSearchAreas(): Promise { + const site = CoreSites.getRequiredCurrentSite(); + const params: CoreSearchGetSearchAreasListWSParams = {}; + + const { areas } = await site.read('core_search_get_search_areas_list', params); + + return areas.map(area => ({ + id: area.id, + name: area.name, + category: { + id: area.categoryid, + name: area.categoryname, + }, + })); + } + + /** + * Log event for viewing results. + * + * @param query Search query. + * @param filters Search filters. + */ + async logViewResults(query: string, filters: CoreSearchGlobalSearchFilters): Promise { + const site = CoreSites.getRequiredCurrentSite(); + const params: CoreSearchViewResultsWSParams = { + query, + filters: await this.prepareWSFilters(filters), + }; + + await CorePushNotifications.logViewListEvent('search', 'core_search_view_results', params); + await site.write('core_search_view_results', params); + } + + /** + * Format a WS result to be used in the app. + * + * @param wsResult WS result. + * @returns App result. + */ + protected async formatWSResult(wsResult: CoreSearchWSResult): Promise { + const result: CoreSearchGlobalSearchResult = { + id: wsResult.itemid, + title: wsResult.title, + url: wsResult.docurl, + content: wsResult.content, + }; + + if (wsResult.componentname === 'core_user') { + const user = await CoreUser.getProfile(wsResult.itemid); + + result.user = user; + } else if (wsResult.componentname === 'core_course') { + const course = await CoreCourses.getCourse(wsResult.itemid); + + result.course = course; + } else { + if (wsResult.userfullname || wsResult.coursefullname) { + result.context = { + userName: wsResult.userfullname, + courseName: wsResult.coursefullname, + }; + } + + if (wsResult.iconurl && wsResult.componentname.startsWith('mod_')) { + result.module = { + name: wsResult.componentname.substr(4), + iconurl: wsResult.iconurl, + }; + } + } + + return result; + } + + /** + * Prepare search filters before sending to WS. + * + * @param filters App filters. + * @returns WS filters. + */ + protected async prepareWSFilters(filters: CoreSearchGlobalSearchFilters): Promise { + const wsFilters: CoreSearchBasicWSFilters = {}; + + if (filters.courseIds) { + wsFilters.courseids = filters.courseIds; + } + + if (filters.searchAreaCategoryIds) { + const searchAreas = await this.getSearchAreas(); + + wsFilters.areaids = searchAreas + .filter(({ category }) => filters.searchAreaCategoryIds?.includes(category.id)) + .map(({ id }) => id); + } + + return wsFilters; + } + +} + +export const CoreSearchGlobalSearch = makeSingleton(CoreSearchGlobalSearchService); + +/** + * Params of core_search_get_results WS. + */ +type CoreSearchGetResultsWSParams = { + query: string; // The search query. + filters?: CoreSearchAdvancedWSFilters; // Filters to apply. + page?: number; // Results page number starting from 0, defaults to the first page. +}; + +/** + * Params of core_search_get_search_areas_list WS. + */ +type CoreSearchGetSearchAreasListWSParams = { + cat?: string; // Category to filter areas. +}; + +/** + * Params of core_search_view_results WS. + */ +type CoreSearchViewResultsWSParams = { + query: string; // The search query. + filters?: CoreSearchBasicWSFilters; // Filters to apply. + page?: number; // Results page number starting from 0, defaults to the first page. +}; + +/** + * Params of core_search_get_top_results WS. + */ +type CoreSearchGetTopResultsWSParams = { + query: string; // The search query. + filters?: CoreSearchAdvancedWSFilters; // Filters to apply. +}; + +/** + * Search result returned in WS. + */ +type CoreSearchWSResult = { // Search results. + itemid: number; // Unique id in the search area scope. + componentname: string; // Component name. + areaname: string; // Search area name. + courseurl: string; // Result course url. + coursefullname: string; // Result course fullname. + timemodified: number; // Result modified time. + title: string; // Result title. + docurl: string; // Result url. + iconurl?: string; // Icon url. + content?: string; // Result contents. + contextid: number; // Result context id. + contexturl: string; // Result context url. + description1?: string; // Extra result contents, depends on the search area. + description2?: string; // Extra result contents, depends on the search area. + multiplefiles?: number; // Whether multiple files are returned or not. + filenames?: string[]; // Result file names if present. + filename?: string; // Result file name if present. + userid?: number; // User id. + userurl?: string; // User url. + userfullname?: string; // User fullname. + textformat: number; // Text fields format, it is the same for all of them. +}; + +/** + * Basic search filters used in WS. + */ +type CoreSearchBasicWSFilters = { + title?: string; // Result title. + areaids?: string[]; // Restrict results to these areas. + courseids?: number[]; // Restrict results to these courses. + timestart?: number; // Docs modified after this date. + timeend?: number; // Docs modified before this date. +}; + +/** + * Advanced search filters used in WS. + */ +type CoreSearchAdvancedWSFilters = CoreSearchBasicWSFilters & { + contextids?: number[]; // Restrict results to these contexts. + cat?: string; // Category to filter areas. + userids?: number[]; // Restrict results to these users. + groupids?: number[]; // Restrict results to these groups. + mycoursesonly?: boolean; // Only results from my courses. + order?: string; // How to order. +}; + +/** + * Data returned by core_search_get_results WS. + */ +type CoreSearchGetResultsWSResponse = { + totalcount: number; // Total number of results. + results?: CoreSearchWSResult[]; +}; + +/** + * Data returned by core_search_get_search_areas_list WS. + */ +type CoreSearchGetSearchAreasListWSResponse = { + areas: { // Search areas. + id: string; // Search area id. + categoryid: string; // Category id. + categoryname: string; // Category name. + name: string; // Search area name. + }[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Data returned by core_search_view_results WS. + */ +type CoreSearchViewResultsWSResponse = { + status: boolean; // Status: true if success. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Data returned by core_search_get_top_results WS. + */ +type CoreSearchGetTopResultsWSResponse = { + results?: CoreSearchWSResult[]; +}; diff --git a/src/core/features/search/services/handlers/mainmenu.ts b/src/core/features/search/services/handlers/mainmenu.ts new file mode 100644 index 00000000000..926263720a6 --- /dev/null +++ b/src/core/features/search/services/handlers/mainmenu.ts @@ -0,0 +1,56 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { makeSingleton } from '@singletons'; +import { CoreMainMenuHandler, CoreMainMenuHandlerData } from '@features/mainmenu/services/mainmenu-delegate'; +import { CoreSites } from '@services/sites'; + +export const CORE_SEARCH_PAGE_NAME = 'search'; + +/** + * Handler to inject an option into main menu. + */ +@Injectable({ providedIn: 'root' }) +export class CoreSearchMainMenuHandlerService implements CoreMainMenuHandler { + + name = 'CoreSearch'; + priority = 575; + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + const site = CoreSites.getRequiredCurrentSite(); + + return !site.isFeatureDisabled('CoreNoDelegate_GlobalSearch') + && site.wsAvailable('core_search_get_results') + && site.canUseAdvancedFeature('enableglobalsearch'); + } + + /** + * @inheritdoc + */ + getDisplayData(): CoreMainMenuHandlerData { + return { + icon: 'fas-magnifying-glass', + title: 'core.search.globalsearch', + page: CORE_SEARCH_PAGE_NAME, + class: 'core-search-handler', + }; + } + +} + +export const CoreSearchMainMenuHandler = makeSingleton(CoreSearchMainMenuHandlerService); diff --git a/src/core/features/search/tests/behat/global-search.feature b/src/core/features/search/tests/behat/global-search.feature new file mode 100644 index 00000000000..deeaae785c1 --- /dev/null +++ b/src/core/features/search/tests/behat/global-search.feature @@ -0,0 +1,99 @@ +@core @core_search @app @javascript +Feature: Test Global Search + + Background: + Given solr is installed + And the following config values are set as admin: + | enableglobalsearch | 1 | + | searchengine | solr | + And the following "courses" exist: + | fullname | shortname | + | Course 1 | C1 | + | Course 2 | C2 | + And the following "users" exist: + | username | + | student1 | + And the following "course enrolments" exist: + | user | course | role | + | student1 | C1 | student | + | student1 | C2 | student | + And the following "activities" exist: + | activity | name | course | idnumber | + | page | Test page 01 | C1 | page01 | + | page | Test page 02 | C1 | page02 | + | page | Test page 03 | C1 | page03 | + | page | Test page 04 | C1 | page04 | + | page | Test page 05 | C1 | page05 | + | page | Test page 06 | C1 | page06 | + | page | Test page 07 | C1 | page07 | + | page | Test page 08 | C1 | page08 | + | page | Test page 09 | C1 | page09 | + | page | Test page 10 | C1 | page10 | + | page | Test page 11 | C1 | page11 | + | page | Test page 12 | C1 | page12 | + | page | Test page 13 | C1 | page13 | + | page | Test page 14 | C1 | page14 | + | page | Test page 15 | C1 | page15 | + | page | Test page 16 | C1 | page16 | + | page | Test page 17 | C1 | page17 | + | page | Test page 18 | C1 | page18 | + | page | Test page 19 | C1 | page19 | + | page | Test page 20 | C1 | page20 | + | page | Test page 21 | C1 | page21 | + | page | Test page C2 | C2 | pagec2 | + And the following "activities" exist: + | activity | name | intro | course | idnumber | + | forum | Test forum | Test forum intro | C1 | forum | + + Scenario: Search in a site + Given global search expects the query "page" and will return: + | type | idnumber | + | activity | page01 | + | activity | page02 | + | activity | page03 | + | activity | page04 | + | activity | page05 | + | activity | page06 | + | activity | page07 | + | activity | page08 | + | activity | page09 | + | activity | page10 | + | activity | page11 | + | activity | page12 | + | activity | pagec2 | + And I entered the app as "student1" + When I press the more menu button in the app + And I press "Global search" in the app + And I set the field "Search" to "page" in the app + And I press "Search" "button" in the app + Then I should find "Test page 01" in the app + And I should find "Test page 10" in the app + + When I load more items in the app + Then I should find "Test page 11" in the app + + When I press "Test page 01" in the app + Then I should find "Test page content" in the app + + When I press the back button in the app + And global search expects the query "forum" and will return: + | type | idnumber | + | activity | forum | + And I set the field "Search" to "forum" in the app + And I press "Search" "button" in the app + Then I should find "Test forum" in the app + But I should not find "Test page" in the app + + When I press "Test forum" in the app + Then I should find "Test forum intro" in the app + + # TODO test other results like course, user, and messages (global search generator not supported) + + Scenario: See search banner + Given the following config values are set as admin: + | searchbannerenable | 1 | + | searchbanner | Search indexing is under maintentance! | + And I entered the app as "student1" + When I press the more menu button in the app + And I press "Global search" in the app + Then I should find "Search indexing is under maintentance!" in the app diff --git a/src/core/services/utils/dom.ts b/src/core/services/utils/dom.ts index 83eb3c62c50..f615f15fac1 100644 --- a/src/core/services/utils/dom.ts +++ b/src/core/services/utils/dom.ts @@ -1504,6 +1504,28 @@ export class CoreDomUtilsProvider { return loading; } + /** + * Show a loading modal whilst an operation is running, and an error modal if it fails. + * + * @param text Loading dialog text. + * @param needsTranslate Whether the 'text' needs to be translated. + * @param operation Operation. + * @returns Operation result. + */ + async showOperationModals(text: string, needsTranslate: boolean, operation: () => Promise): Promise { + const modal = await this.showModalLoading(text, needsTranslate); + + try { + return await operation(); + } catch (error) { + CoreDomUtils.showErrorModal(error); + + return null; + } finally { + modal.dismiss(); + } + } + /** * Show a modal warning the user that he should use a different app. *