From cd85155953c675fbe8e724c80b3c0ddece36e166 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Tue, 18 Jul 2023 16:59:40 +0900 Subject: [PATCH] MOBILE-4207 forum: Implement search forums block --- scripts/langindex.json | 2 + src/addons/block/block.module.ts | 2 + src/addons/block/searchforums/lang.json | 3 + .../block/searchforums/searchforums.module.ts | 38 ++++++ .../searchforums/services/block-handler.ts | 68 ++++++++++ .../mod/forum/forum-search-lazy.module.ts | 38 ++++++ src/addons/mod/forum/forum.module.ts | 6 + src/addons/mod/forum/lang.json | 1 + src/addons/mod/forum/pages/search/search.html | 50 ++++++++ src/addons/mod/forum/pages/search/search.scss | 15 +++ src/addons/mod/forum/pages/search/search.ts | 119 ++++++++++++++++++ .../mod/forum/tests/behat/search.feature | 49 ++++++++ .../classes/global-search-results-source.ts | 13 ++ .../search/components/components.module.ts | 3 + .../features/search/search-lazy.module.ts | 2 - .../features/search/services/global-search.ts | 21 +++- 16 files changed, 425 insertions(+), 5 deletions(-) create mode 100644 src/addons/block/searchforums/lang.json create mode 100644 src/addons/block/searchforums/searchforums.module.ts create mode 100644 src/addons/block/searchforums/services/block-handler.ts create mode 100644 src/addons/mod/forum/forum-search-lazy.module.ts create mode 100644 src/addons/mod/forum/pages/search/search.html create mode 100644 src/addons/mod/forum/pages/search/search.scss create mode 100644 src/addons/mod/forum/pages/search/search.ts create mode 100644 src/addons/mod/forum/tests/behat/search.feature diff --git a/scripts/langindex.json b/scripts/langindex.json index 90906b272..709e101f2 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -66,6 +66,7 @@ "addon.block_recentlyaccesseditems.noitems": "block_recentlyaccesseditems", "addon.block_recentlyaccesseditems.pluginname": "block_recentlyaccesseditems", "addon.block_rssclient.pluginname": "block_rss_client", + "addon.block_searchforums.pluginname": "block_search_forums", "addon.block_selfcompletion.pluginname": "block_selfcompletion", "addon.block_sitemainmenu.pluginname": "block_site_main_menu", "addon.block_starredcourses.nocourses": "block_starredcourses", @@ -684,6 +685,7 @@ "addon.mod_forum.removefromfavourites": "forum", "addon.mod_forum.reply": "forum", "addon.mod_forum.replyplaceholder": "forum", + "addon.mod_forum.searchresults": "course", "addon.mod_forum.subject": "forum", "addon.mod_forum.tagarea_forum_posts": "forum", "addon.mod_forum.thisforumhasduedate": "forum", diff --git a/src/addons/block/block.module.ts b/src/addons/block/block.module.ts index 0989bedf9..3e353219d 100644 --- a/src/addons/block/block.module.ts +++ b/src/addons/block/block.module.ts @@ -42,6 +42,7 @@ import { AddonBlockStarredCoursesModule } from './starredcourses/starredcourses. import { AddonBlockTagsModule } from './tags/tags.module'; import { AddonBlockTimelineModule } from './timeline/timeline.module'; import { AddonBlockGlobalSearchModule } from '@addons/block/globalsearch/globalsearch.module'; +import { AddonBlockSearchForumsModule } from '@addons/block/searchforums/searchforums.module'; @NgModule({ imports: [ @@ -68,6 +69,7 @@ import { AddonBlockGlobalSearchModule } from '@addons/block/globalsearch/globals AddonBlockRecentlyAccessedCoursesModule, AddonBlockRecentlyAccessedItemsModule, AddonBlockRssClientModule, + AddonBlockSearchForumsModule, AddonBlockSelfCompletionModule, AddonBlockSiteMainMenuModule, AddonBlockStarredCoursesModule, diff --git a/src/addons/block/searchforums/lang.json b/src/addons/block/searchforums/lang.json new file mode 100644 index 000000000..39f2d3380 --- /dev/null +++ b/src/addons/block/searchforums/lang.json @@ -0,0 +1,3 @@ +{ + "pluginname": "Search forums" +} diff --git a/src/addons/block/searchforums/searchforums.module.ts b/src/addons/block/searchforums/searchforums.module.ts new file mode 100644 index 000000000..d2299d28b --- /dev/null +++ b/src/addons/block/searchforums/searchforums.module.ts @@ -0,0 +1,38 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreBlockDelegate } from '@features/block/services/block-delegate'; +import { AddonBlockSearchForumsHandler } from './services/block-handler'; +import { CoreBlockComponentsModule } from '@features/block/components/components.module'; + +@NgModule({ + imports: [ + IonicModule, + CoreBlockComponentsModule, + TranslateModule.forChild(), + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + useValue: () => { + CoreBlockDelegate.registerHandler(AddonBlockSearchForumsHandler.instance); + }, + }, + ], +}) +export class AddonBlockSearchForumsModule {} diff --git a/src/addons/block/searchforums/services/block-handler.ts b/src/addons/block/searchforums/services/block-handler.ts new file mode 100644 index 000000000..c21aa4112 --- /dev/null +++ b/src/addons/block/searchforums/services/block-handler.ts @@ -0,0 +1,68 @@ +// (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 { CoreBlockHandlerData } from '@features/block/services/block-delegate'; +import { CoreBlockOnlyTitleComponent } from '@features/block/components/only-title-block/only-title-block'; +import { CoreBlockBaseHandler } from '@features/block/classes/base-block-handler'; +import { makeSingleton } from '@singletons'; +import { FORUM_SEARCH_PAGE_NAME } from '@addons/mod/forum/forum.module'; +import { CoreCourseBlock } from '@features/course/services/course'; +import { CoreSearchGlobalSearch } from '@features/search/services/global-search'; + +/** + * Block handler. + */ +@Injectable({ providedIn: 'root' }) +export class AddonBlockSearchForumsHandlerService extends CoreBlockBaseHandler { + + name = 'AddonBlockSearchForums'; + blockName = 'search_forums'; + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + const enabled = await CoreSearchGlobalSearch.isEnabled(); + + if (!enabled) { + return false; + } + + const forumSearchAreas = ['mod_forum-activity', 'mod_forum-post']; + const searchAreas = await CoreSearchGlobalSearch.getSearchAreas(); + + return searchAreas.some(({ id }) => forumSearchAreas.includes(id)); + } + + /** + * @inheritdoc + */ + getDisplayData(block: CoreCourseBlock, contextLevel: string, instanceId: number): CoreBlockHandlerData | undefined { + if (contextLevel !== 'course') { + return; + } + + return { + title: 'addon.block_searchforums.pluginname', + class: 'addon-block-search-forums', + component: CoreBlockOnlyTitleComponent, + link: FORUM_SEARCH_PAGE_NAME, + linkParams: { courseId: instanceId }, + }; + } + +} + +export const AddonBlockSearchForumsHandler = makeSingleton(AddonBlockSearchForumsHandlerService); diff --git a/src/addons/mod/forum/forum-search-lazy.module.ts b/src/addons/mod/forum/forum-search-lazy.module.ts new file mode 100644 index 000000000..64e02c6ba --- /dev/null +++ b/src/addons/mod/forum/forum-search-lazy.module.ts @@ -0,0 +1,38 @@ +// (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 { AddonModForumSearchPage } from '@addons/mod/forum/pages/search/search'; +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { CoreMainMenuComponentsModule } from '@features/mainmenu/components/components.module'; +import { CoreSearchComponentsModule } from '@features/search/components/components.module'; + +const routes: Routes = [{ + path: '', + component: AddonModForumSearchPage, +}]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CoreSharedModule, + CoreSearchComponentsModule, + CoreMainMenuComponentsModule, + ], + declarations: [ + AddonModForumSearchPage, + ], +}) +export class AddonModForumSearchLazyModule {} diff --git a/src/addons/mod/forum/forum.module.ts b/src/addons/mod/forum/forum.module.ts index f5ec2d6c6..b0a4a78b0 100644 --- a/src/addons/mod/forum/forum.module.ts +++ b/src/addons/mod/forum/forum.module.ts @@ -52,7 +52,13 @@ export const ADDON_MOD_FORUM_SERVICES: Type[] = [ AddonModForumSyncProvider, ]; +export const FORUM_SEARCH_PAGE_NAME = 'forum/search'; + const mainMenuRoutes: Routes = [ + { + path: FORUM_SEARCH_PAGE_NAME, + loadChildren: () => import('./forum-search-lazy.module').then(m => m.AddonModForumSearchLazyModule), + }, { path: `${AddonModForumModuleHandlerService.PAGE_NAME}/discussion/:discussionId`, loadChildren: () => import('./forum-discussion-lazy.module').then(m => m.AddonModForumDiscussionLazyModule), diff --git a/src/addons/mod/forum/lang.json b/src/addons/mod/forum/lang.json index 46277f2df..42c33b92a 100644 --- a/src/addons/mod/forum/lang.json +++ b/src/addons/mod/forum/lang.json @@ -59,6 +59,7 @@ "removefromfavourites": "Unstar this discussion", "reply": "Reply", "replyplaceholder": "Write your reply...", + "searchresults": "Search results: {{$a}}", "subject": "Subject", "tagarea_forum_posts": "Forum posts", "thisforumhasduedate": "The due date for posting to this forum is {{$a}}.", diff --git a/src/addons/mod/forum/pages/search/search.html b/src/addons/mod/forum/pages/search/search.html new file mode 100644 index 000000000..759c8b5b5 --- /dev/null +++ b/src/addons/mod/forum/pages/search/search.html @@ -0,0 +1,50 @@ + + + + + + +

{{ 'addon.block_searchforums.pluginname' | translate }}

+
+ + + +
+
+ +
+ + + + + + + + + + + +
+ {{ 'addon.mod_forum.searchresults' | translate: { $a: resultsSource.getTotalResults() } }} +
+ + + + + + + + + + +

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

+ +

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

+

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

+
+
+
+
diff --git a/src/addons/mod/forum/pages/search/search.scss b/src/addons/mod/forum/pages/search/search.scss new file mode 100644 index 000000000..99577941a --- /dev/null +++ b/src/addons/mod/forum/pages/search/search.scss @@ -0,0 +1,15 @@ +:host { + --results-count-text-color: var(--gray-700); + + .results-count { + color: var(--results-count-text-color); + min-height: 0px; + margin: 8px 16px; + font-size: 14px; + } + +} + +:host-context(html.dark) { + --results-count-text-color: var(--gray-400); +} diff --git a/src/addons/mod/forum/pages/search/search.ts b/src/addons/mod/forum/pages/search/search.ts new file mode 100644 index 000000000..388ea1a8b --- /dev/null +++ b/src/addons/mod/forum/pages/search/search.ts @@ -0,0 +1,119 @@ +// (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 { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper'; +import { CoreSearchGlobalSearchResultsSource } from '@features/search/classes/global-search-results-source'; +import { + CoreSearchGlobalSearch, + CoreSearchGlobalSearchFilters, + CoreSearchGlobalSearchResult, +} from '@features/search/services/global-search'; +import { CoreNavigator } from '@services/navigator'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; + +@Component({ + selector: 'page-addon-mod-forum-search', + templateUrl: 'search.html', + styleUrls: ['search.scss'], +}) +export class AddonModForumSearchPage implements OnInit { + + loadMoreError: string | null = null; + searchBanner: string | null = null; + resultsSource = new CoreSearchGlobalSearchResultsSource('', {}); + searchAreaId?: string; + + /** + * @inheritdoc + */ + ngOnInit(): void { + try { + const site = CoreSites.getRequiredCurrentSite(); + const searchBanner = site.config?.searchbanner?.trim() ?? ''; + const courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); + const filters: CoreSearchGlobalSearchFilters = { + courseIds: [courseId], + }; + + if (CoreUtils.isTrueOrOne(site.config?.searchbannerenable) && searchBanner.length > 0) { + this.searchBanner = searchBanner; + } + + filters.searchAreaIds = ['mod_forum-activity', 'mod_forum-post']; + this.searchAreaId = `AddonModForumSearch-${courseId}`; + + this.resultsSource.setFilters(filters); + } catch (error) { + CoreDomUtils.showErrorModal(error); + CoreNavigator.back(); + + return; + } + } + + /** + * 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/addons/mod/forum/tests/behat/search.feature b/src/addons/mod/forum/tests/behat/search.feature new file mode 100644 index 000000000..4fccc6a5e --- /dev/null +++ b/src/addons/mod/forum/tests/behat/search.feature @@ -0,0 +1,49 @@ +@mod @mod_forum @app @javascript @lms_from4.3 +Feature: Test Forum 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 | intro | course | idnumber | + | forum | Test forum 1 | Test forum 1 intro | C1 | forum1 | + | forum | Test forum 2 | Test forum 2 intro | C1 | forum2 | + | forum | Test forum 3 | Test forum 3 intro | C2 | forum3 | + And the following "mod_forum > discussions" exist: + | forum | name | subject | message | + | forum1 | Initial discussion 1 | Initial discussion 1 | Initial discussion message 1 | + | forum2 | Initial discussion 2 | Initial discussion 2 | Initial discussion message 2 | + | forum3 | Initial discussion 3 | Initial discussion 3 | Initial discussion message 3 | + + Scenario: Search in side block + Given global search expects the query "message" and will return: + | type | idnumber | + | activity | forum1 | + | activity | forum2 | + And the following "blocks" exist: + | blockname | contextlevel | reference | + | search_forums | Course | C1 | + And I entered the course "Course 1" as "student1" in the app + When I press "Open block drawer" in the app + And I press "Search forums" in the app + Then I should find "What are you searching for?" in the app + And I should find "Search forums" in the app + + When I set the field "Search" to "message" in the app + And I press "Search" "button" in the app + Then I should find "Search results: 2" in the app + And I should find "Test forum 1" in the app + And I should find "Test forum 2" in the app diff --git a/src/core/features/search/classes/global-search-results-source.ts b/src/core/features/search/classes/global-search-results-source.ts index 183f0c4af..26caf6be3 100644 --- a/src/core/features/search/classes/global-search-results-source.ts +++ b/src/core/features/search/classes/global-search-results-source.ts @@ -28,6 +28,7 @@ export class CoreSearchGlobalSearchResultsSource extends CorePaginatedItemsManag private query: string; private filters: CoreSearchGlobalSearchFilters; private pagesLoaded = 0; + private totalResults?: number; private topResultsIds?: number[]; constructor(query: string, filters: CoreSearchGlobalSearchFilters) { @@ -93,6 +94,15 @@ export class CoreSearchGlobalSearchResultsSource extends CorePaginatedItemsManag return this.pagesLoaded; } + /** + * Get total results with the given filter. + * + * @returns Total results. + */ + getTotalResults(): number | null { + return this.totalResults ?? null; + } + /** * @inheritdoc */ @@ -107,6 +117,7 @@ export class CoreSearchGlobalSearchResultsSource extends CorePaginatedItemsManag */ reset(): void { this.pagesLoaded = 0; + delete this.totalResults; delete this.topResultsIds; super.reset(); @@ -130,6 +141,8 @@ export class CoreSearchGlobalSearchResultsSource extends CorePaginatedItemsManag const pageResults = await CoreSearchGlobalSearch.getResults(this.query, this.filters, page); + this.totalResults = pageResults.total; + results.push(...pageResults.results.filter(result => !this.topResultsIds?.includes(result.id))); return { items: results, hasMoreItems: pageResults.canLoadMore }; diff --git a/src/core/features/search/components/components.module.ts b/src/core/features/search/components/components.module.ts index 2c935312a..730dc349b 100644 --- a/src/core/features/search/components/components.module.ts +++ b/src/core/features/search/components/components.module.ts @@ -16,16 +16,19 @@ 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/search-lazy.module.ts b/src/core/features/search/search-lazy.module.ts index 61c70cce0..bc07b9618 100644 --- a/src/core/features/search/search-lazy.module.ts +++ b/src/core/features/search/search-lazy.module.ts @@ -19,7 +19,6 @@ 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. @@ -42,7 +41,6 @@ function buildRoutes(injector: Injector): Routes { exports: [RouterModule], declarations: [ CoreSearchGlobalSearchPage, - CoreSearchGlobalSearchResultComponent, ], providers: [ { diff --git a/src/core/features/search/services/global-search.ts b/src/core/features/search/services/global-search.ts index c4cbbe0e9..ecbf8b612 100644 --- a/src/core/features/search/services/global-search.ts +++ b/src/core/features/search/services/global-search.ts @@ -78,6 +78,7 @@ export type CoreSearchGlobalSearchSearchArea = { export interface CoreSearchGlobalSearchFilters { searchAreaCategoryIds?: string[]; + searchAreaIds?: string[]; courseIds?: number[]; } @@ -116,10 +117,11 @@ export class CoreSearchGlobalSearchService { query: string, filters: CoreSearchGlobalSearchFilters, page: number, - ): Promise<{ results: CoreSearchGlobalSearchResult[]; canLoadMore: boolean }> { + ): Promise<{ results: CoreSearchGlobalSearchResult[]; total: number; canLoadMore: boolean }> { if (this.filtersYieldEmptyResults(filters)) { return { results: [], + total: 0, canLoadMore: false, }; } @@ -136,6 +138,7 @@ export class CoreSearchGlobalSearchService { return { results: await Promise.all((results ?? []).map(result => this.formatWSResult(result))), + total: totalcount, canLoadMore: totalcount > (page + 1) * CORE_SEARCH_GLOBAL_SEARCH_PAGE_LENGTH, }; } @@ -269,7 +272,9 @@ export class CoreSearchGlobalSearchService { * @returns Whether the given filters will return 0 results. */ protected filtersYieldEmptyResults(filters: CoreSearchGlobalSearchFilters): boolean { - return filters.courseIds?.length === 0 || filters.searchAreaCategoryIds?.length === 0; + return filters.courseIds?.length === 0 + || filters.searchAreaIds?.length === 0 + || filters.searchAreaCategoryIds?.length === 0; } /** @@ -285,11 +290,21 @@ export class CoreSearchGlobalSearchService { wsFilters.courseids = filters.courseIds; } + if (filters.searchAreaIds) { + wsFilters.areaids = filters.searchAreaIds; + } + if (filters.searchAreaCategoryIds) { const searchAreas = await this.getSearchAreas(); wsFilters.areaids = searchAreas - .filter(({ category }) => filters.searchAreaCategoryIds?.includes(category.id)) + .filter(({ id, category }) => { + if (filters.searchAreaIds && !filters.searchAreaIds.includes(id)) { + return false; + } + + return filters.searchAreaCategoryIds?.includes(category.id); + }) .map(({ id }) => id); }