Merge pull request #3743 from NoelDeMartin/MOBILE-4207

MOBILE-4207: Forum Search
main
Dani Palou 2023-09-27 11:41:48 +02:00 committed by GitHub
commit 566db5205e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 553 additions and 31 deletions

View File

@ -66,6 +66,7 @@
"addon.block_recentlyaccesseditems.noitems": "block_recentlyaccesseditems", "addon.block_recentlyaccesseditems.noitems": "block_recentlyaccesseditems",
"addon.block_recentlyaccesseditems.pluginname": "block_recentlyaccesseditems", "addon.block_recentlyaccesseditems.pluginname": "block_recentlyaccesseditems",
"addon.block_rssclient.pluginname": "block_rss_client", "addon.block_rssclient.pluginname": "block_rss_client",
"addon.block_searchforums.pluginname": "block_search_forums",
"addon.block_selfcompletion.pluginname": "block_selfcompletion", "addon.block_selfcompletion.pluginname": "block_selfcompletion",
"addon.block_sitemainmenu.pluginname": "block_site_main_menu", "addon.block_sitemainmenu.pluginname": "block_site_main_menu",
"addon.block_starredcourses.nocourses": "block_starredcourses", "addon.block_starredcourses.nocourses": "block_starredcourses",
@ -684,6 +685,7 @@
"addon.mod_forum.removefromfavourites": "forum", "addon.mod_forum.removefromfavourites": "forum",
"addon.mod_forum.reply": "forum", "addon.mod_forum.reply": "forum",
"addon.mod_forum.replyplaceholder": "forum", "addon.mod_forum.replyplaceholder": "forum",
"addon.mod_forum.searchresults": "course",
"addon.mod_forum.subject": "forum", "addon.mod_forum.subject": "forum",
"addon.mod_forum.tagarea_forum_posts": "forum", "addon.mod_forum.tagarea_forum_posts": "forum",
"addon.mod_forum.thisforumhasduedate": "forum", "addon.mod_forum.thisforumhasduedate": "forum",

View File

@ -42,6 +42,7 @@ import { AddonBlockStarredCoursesModule } from './starredcourses/starredcourses.
import { AddonBlockTagsModule } from './tags/tags.module'; import { AddonBlockTagsModule } from './tags/tags.module';
import { AddonBlockTimelineModule } from './timeline/timeline.module'; import { AddonBlockTimelineModule } from './timeline/timeline.module';
import { AddonBlockGlobalSearchModule } from '@addons/block/globalsearch/globalsearch.module'; import { AddonBlockGlobalSearchModule } from '@addons/block/globalsearch/globalsearch.module';
import { AddonBlockSearchForumsModule } from '@addons/block/searchforums/searchforums.module';
@NgModule({ @NgModule({
imports: [ imports: [
@ -68,6 +69,7 @@ import { AddonBlockGlobalSearchModule } from '@addons/block/globalsearch/globals
AddonBlockRecentlyAccessedCoursesModule, AddonBlockRecentlyAccessedCoursesModule,
AddonBlockRecentlyAccessedItemsModule, AddonBlockRecentlyAccessedItemsModule,
AddonBlockRssClientModule, AddonBlockRssClientModule,
AddonBlockSearchForumsModule,
AddonBlockSelfCompletionModule, AddonBlockSelfCompletionModule,
AddonBlockSiteMainMenuModule, AddonBlockSiteMainMenuModule,
AddonBlockStarredCoursesModule, AddonBlockStarredCoursesModule,

View File

@ -0,0 +1,3 @@
{
"pluginname": "Search forums"
}

View File

@ -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 {}

View File

@ -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<boolean> {
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);

View File

@ -1,5 +1,8 @@
<!-- Buttons to add to the header. --> <!-- Buttons to add to the header. -->
<core-navbar-buttons slot="end"> <core-navbar-buttons slot="end">
<ion-button fill="clear" (click)="openSearch()" [attr.aria-label]="'core.search' | translate">
<ion-icon name="fas-magnifying-glass" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="openModuleSummary()" aria-haspopup="true" [attr.aria-label]="'core.info' | translate"> <ion-button fill="clear" (click)="openModuleSummary()" aria-haspopup="true" [attr.aria-label]="'core.info' | translate">
<ion-icon name="fas-circle-info" slot="icon-only" aria-hidden="true"></ion-icon> <ion-icon name="fas-circle-info" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button> </ion-button>

View File

@ -57,6 +57,8 @@ import { AddonModForumDiscussionItem, AddonModForumDiscussionsSource } from '../
import { CoreListItemsManager } from '@classes/items-management/list-items-manager'; import { CoreListItemsManager } from '@classes/items-management/list-items-manager';
import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker';
import { CorePromisedValue } from '@classes/promised-value'; import { CorePromisedValue } from '@classes/promised-value';
import { CoreNavigator } from '@services/navigator';
import { FORUM_SEARCH_PAGE_NAME } from '@addons/mod/forum/forum.module';
/** /**
* Component that displays a forum entry page. * Component that displays a forum entry page.
@ -332,6 +334,22 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom
this.discussions?.destroy(); this.discussions?.destroy();
} }
/**
* Open search page.
*/
async openSearch(): Promise<void> {
if (!this.forum) {
return;
}
await CoreNavigator.navigateToSitePath(FORUM_SEARCH_PAGE_NAME, {
params: {
courseId: this.courseId,
forumId: this.forum.id,
},
});
}
/** /**
* @inheritdoc * @inheritdoc
*/ */

View File

@ -36,4 +36,4 @@ const routes: Routes = [{
AddonModForumDiscussionPage, AddonModForumDiscussionPage,
], ],
}) })
export class AddonForumDiscussionLazyModule {} export class AddonModForumDiscussionLazyModule {}

View File

@ -29,15 +29,15 @@ const mobileRoutes: Routes = [
}, },
{ {
path: ':courseId/:cmId/new/:timeCreated', path: ':courseId/:cmId/new/:timeCreated',
loadChildren: () => import('./forum-new-discussion-lazy.module').then(m => m.AddonForumNewDiscussionLazyModule), loadChildren: () => import('./forum-new-discussion-lazy.module').then(m => m.AddonModForumNewDiscussionLazyModule),
}, },
{ {
path: ':courseId/:cmId/:discussionId', path: ':courseId/:cmId/:discussionId',
loadChildren: () => import('./forum-discussion-lazy.module').then(m => m.AddonForumDiscussionLazyModule), loadChildren: () => import('./forum-discussion-lazy.module').then(m => m.AddonModForumDiscussionLazyModule),
}, },
{ {
path: 'discussion/:discussionId', // Only for discussion link handling. path: 'discussion/:discussionId', // Only for discussion link handling.
loadChildren: () => import('./forum-discussion-lazy.module').then(m => m.AddonForumDiscussionLazyModule), loadChildren: () => import('./forum-discussion-lazy.module').then(m => m.AddonModForumDiscussionLazyModule),
}, },
]; ];
@ -48,11 +48,11 @@ const tabletRoutes: Routes = [
children: [ children: [
{ {
path: 'new/:timeCreated', path: 'new/:timeCreated',
loadChildren: () => import('./forum-new-discussion-lazy.module').then(m => m.AddonForumNewDiscussionLazyModule), loadChildren: () => import('./forum-new-discussion-lazy.module').then(m => m.AddonModForumNewDiscussionLazyModule),
}, },
{ {
path: ':discussionId', path: ':discussionId',
loadChildren: () => import('./forum-discussion-lazy.module').then(m => m.AddonForumDiscussionLazyModule), loadChildren: () => import('./forum-discussion-lazy.module').then(m => m.AddonModForumDiscussionLazyModule),
}, },
], ],
}, },

View File

@ -38,4 +38,4 @@ const routes: Routes = [{
AddonModForumNewDiscussionPage, AddonModForumNewDiscussionPage,
], ],
}) })
export class AddonForumNewDiscussionLazyModule {} export class AddonModForumNewDiscussionLazyModule {}

View File

@ -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 {}

View File

@ -52,10 +52,16 @@ export const ADDON_MOD_FORUM_SERVICES: Type<unknown>[] = [
AddonModForumSyncProvider, AddonModForumSyncProvider,
]; ];
export const FORUM_SEARCH_PAGE_NAME = 'forum/search';
const mainMenuRoutes: Routes = [ const mainMenuRoutes: Routes = [
{
path: FORUM_SEARCH_PAGE_NAME,
loadChildren: () => import('./forum-search-lazy.module').then(m => m.AddonModForumSearchLazyModule),
},
{ {
path: `${AddonModForumModuleHandlerService.PAGE_NAME}/discussion/:discussionId`, path: `${AddonModForumModuleHandlerService.PAGE_NAME}/discussion/:discussionId`,
loadChildren: () => import('./forum-discussion-lazy.module').then(m => m.AddonForumDiscussionLazyModule), loadChildren: () => import('./forum-discussion-lazy.module').then(m => m.AddonModForumDiscussionLazyModule),
data: { swipeEnabled: false }, data: { swipeEnabled: false },
}, },
{ {
@ -66,12 +72,12 @@ const mainMenuRoutes: Routes = [
[ [
{ {
path: `${COURSE_CONTENTS_PATH}/${AddonModForumModuleHandlerService.PAGE_NAME}/new/:timeCreated`, path: `${COURSE_CONTENTS_PATH}/${AddonModForumModuleHandlerService.PAGE_NAME}/new/:timeCreated`,
loadChildren: () => import('./forum-new-discussion-lazy.module').then(m => m.AddonForumNewDiscussionLazyModule), loadChildren: () => import('./forum-new-discussion-lazy.module').then(m => m.AddonModForumNewDiscussionLazyModule),
data: { discussionsPathPrefix: `${AddonModForumModuleHandlerService.PAGE_NAME}/` }, data: { discussionsPathPrefix: `${AddonModForumModuleHandlerService.PAGE_NAME}/` },
}, },
{ {
path: `${COURSE_CONTENTS_PATH}/${AddonModForumModuleHandlerService.PAGE_NAME}/:discussionId`, path: `${COURSE_CONTENTS_PATH}/${AddonModForumModuleHandlerService.PAGE_NAME}/:discussionId`,
loadChildren: () => import('./forum-discussion-lazy.module').then(m => m.AddonForumDiscussionLazyModule), loadChildren: () => import('./forum-discussion-lazy.module').then(m => m.AddonModForumDiscussionLazyModule),
data: { discussionsPathPrefix: `${AddonModForumModuleHandlerService.PAGE_NAME}/` }, data: { discussionsPathPrefix: `${AddonModForumModuleHandlerService.PAGE_NAME}/` },
}, },
], ],
@ -83,12 +89,12 @@ const courseContentsRoutes: Routes = conditionalRoutes(
[ [
{ {
path: `${AddonModForumModuleHandlerService.PAGE_NAME}/new/:timeCreated`, path: `${AddonModForumModuleHandlerService.PAGE_NAME}/new/:timeCreated`,
loadChildren: () => import('./forum-new-discussion-lazy.module').then(m => m.AddonForumNewDiscussionLazyModule), loadChildren: () => import('./forum-new-discussion-lazy.module').then(m => m.AddonModForumNewDiscussionLazyModule),
data: { discussionsPathPrefix: `${AddonModForumModuleHandlerService.PAGE_NAME}/` }, data: { discussionsPathPrefix: `${AddonModForumModuleHandlerService.PAGE_NAME}/` },
}, },
{ {
path: `${AddonModForumModuleHandlerService.PAGE_NAME}/:discussionId`, path: `${AddonModForumModuleHandlerService.PAGE_NAME}/:discussionId`,
loadChildren: () => import('./forum-discussion-lazy.module').then(m => m.AddonForumDiscussionLazyModule), loadChildren: () => import('./forum-discussion-lazy.module').then(m => m.AddonModForumDiscussionLazyModule),
data: { discussionsPathPrefix: `${AddonModForumModuleHandlerService.PAGE_NAME}/` }, data: { discussionsPathPrefix: `${AddonModForumModuleHandlerService.PAGE_NAME}/` },
}, },
], ],

View File

@ -59,6 +59,7 @@
"removefromfavourites": "Unstar this discussion", "removefromfavourites": "Unstar this discussion",
"reply": "Reply", "reply": "Reply",
"replyplaceholder": "Write your reply...", "replyplaceholder": "Write your reply...",
"searchresults": "Search results: {{$a}}",
"subject": "Subject", "subject": "Subject",
"tagarea_forum_posts": "Forum posts", "tagarea_forum_posts": "Forum posts",
"thisforumhasduedate": "The due date for posting to this forum is {{$a}}.", "thisforumhasduedate": "The due date for posting to this forum is {{$a}}.",

View File

@ -0,0 +1,48 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>
<h1 *ngIf="forum">{{ forum.name }}</h1>
<h1 *ngIf="!forum">{{ 'addon.block_searchforums.pluginname' | translate }}</h1>
</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="limited-width">
<div>
<ion-card class="core-danger-card" *ngIf="searchBanner">
<ion-item>
<ion-icon name="fas-triangle-exclamation" slot="start" aria-hidden="true"></ion-icon>
<ion-label>
<core-format-text [text]="searchBanner"></core-format-text>
</ion-label>
</ion-item>
</ion-card>
<core-search-box (onSubmit)="search($event)" (onClear)="clearSearch()" [placeholder]="'core.search' | translate"
[searchLabel]="'core.search' | translate" [autoFocus]="true" [searchArea]="searchAreaId"></core-search-box>
<div *ngIf="!resultsSource.isEmpty()" class="results-count">
{{ 'addon.mod_forum.searchresults' | translate: { $a: resultsSource.getTotalResults() } }}
</div>
<ion-list *ngIf="resultsSource.isLoaded()">
<core-search-global-search-result *ngFor="let result of resultsSource.getItems()" [result]="result" [showCourse]="false"
(onClick)="visitResult(result)">
</core-search-global-search-result>
</ion-list>
<core-infinite-loading [enabled]="resultsSource.isLoaded() && !resultsSource.isCompleted()" (action)="loadMoreResults($event)"
[error]="loadMoreError">
</core-infinite-loading>
<core-empty-box *ngIf="resultsSource.isEmpty()" icon="fas-magnifying-glass" [dimmed]="!resultsSource.isLoaded()">
<p *ngIf="!resultsSource.isLoaded()">{{ 'core.search.empty' | translate }}</p>
<ng-container *ngIf="resultsSource.isLoaded()">
<p><strong>{{ 'core.search.noresults' | translate: { $a: resultsSource.getQuery() } }}</strong></p>
<p><small>{{ 'core.search.noresultshelp' | translate }}</small></p>
</ng-container>
</core-empty-box>
</div>
</ion-content>

View File

@ -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);
}

View File

@ -0,0 +1,161 @@
// (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 { AddonModForum, AddonModForumData } from '@addons/mod/forum/services/forum';
import { Component, OnInit } from '@angular/core';
import { CorePromisedValue } from '@classes/promised-value';
import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper';
import { CoreCourse } from '@features/course/services/course';
import { CoreSearchGlobalSearchResultsSource } from '@features/search/classes/global-search-results-source';
import {
CoreSearchGlobalSearch,
CoreSearchGlobalSearchFilters,
CoreSearchGlobalSearchResult,
} from '@features/search/services/global-search';
import { CoreAnalytics, CoreAnalyticsEventType } from '@services/analytics';
import { CoreNavigator } from '@services/navigator';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUrlUtils } from '@services/utils/url';
import { CoreUtils } from '@services/utils/utils';
import { Translate } from '@singletons';
@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('', {});
forum?: AddonModForumData;
searchAreaId?: string;
private ready = new CorePromisedValue<void>();
/**
* @inheritdoc
*/
async ngOnInit(): Promise<void> {
try {
const site = CoreSites.getRequiredCurrentSite();
const searchBanner = site.config?.searchbanner?.trim() ?? '';
const courseId = CoreNavigator.getRequiredRouteNumberParam('courseId');
const forumId = CoreNavigator.getRouteNumberParam('forumId');
const filters: CoreSearchGlobalSearchFilters = {
courseIds: [courseId],
};
if (CoreUtils.isTrueOrOne(site.config?.searchbannerenable) && searchBanner.length > 0) {
this.searchBanner = searchBanner;
}
if (forumId) {
this.forum = await AddonModForum.getForumById(courseId, forumId);
const module = await CoreCourse.getModule(this.forum.cmid, courseId);
filters.searchAreaIds = ['mod_forum-post'];
if (module.contextid) {
filters.contextIds = [module.contextid];
}
this.searchAreaId = `AddonModForumSearch-${courseId}-${this.forum.id}`;
} else {
filters.searchAreaIds = ['mod_forum-activity', 'mod_forum-post'];
this.searchAreaId = `AddonModForumSearch-${courseId}`;
}
this.resultsSource.setFilters(filters);
this.ready.resolve();
} catch (error) {
CoreDomUtils.showErrorModal(error);
CoreNavigator.back();
return;
}
}
/**
* Perform a new search.
*
* @param query Search query.
*/
async search(query: string): Promise<void> {
await this.ready;
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()),
);
CoreAnalytics.logEvent({
type: CoreAnalyticsEventType.VIEW_ITEM_LIST,
ws: 'core_search_view_results',
name: Translate.instant('core.search.globalsearch'),
data: {
query,
filters: JSON.stringify(this.resultsSource.getFilters()),
},
url: CoreUrlUtils.addParamsToUrl('/search/index.php', {
q: query,
}),
});
});
}
/**
* Clear search results.
*/
clearSearch(): void {
this.loadMoreError = null;
this.resultsSource.setQuery('');
this.resultsSource.reset();
}
/**
* Visit a result's origin.
*
* @param result Result to visit.
*/
async visitResult(result: CoreSearchGlobalSearchResult): Promise<void> {
await CoreContentLinksHelper.handleLink(result.url);
}
/**
* Load more results.
*
* @param complete Notify completion.
*/
async loadMoreResults(complete: () => void ): Promise<void> {
try {
await this.resultsSource?.load();
} catch (error) {
this.loadMoreError = CoreDomUtils.getErrorMessage(error);
} finally {
complete();
}
}
}

View File

@ -0,0 +1,51 @@
@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 |
# TODO test single forum search (lacking generators for post search results)
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

View File

@ -28,6 +28,7 @@ export class CoreSearchGlobalSearchResultsSource extends CorePaginatedItemsManag
private query: string; private query: string;
private filters: CoreSearchGlobalSearchFilters; private filters: CoreSearchGlobalSearchFilters;
private pagesLoaded = 0; private pagesLoaded = 0;
private totalResults?: number;
private topResultsIds?: number[]; private topResultsIds?: number[];
constructor(query: string, filters: CoreSearchGlobalSearchFilters) { constructor(query: string, filters: CoreSearchGlobalSearchFilters) {
@ -93,6 +94,15 @@ export class CoreSearchGlobalSearchResultsSource extends CorePaginatedItemsManag
return this.pagesLoaded; return this.pagesLoaded;
} }
/**
* Get total results with the given filter.
*
* @returns Total results.
*/
getTotalResults(): number | null {
return this.totalResults ?? null;
}
/** /**
* @inheritdoc * @inheritdoc
*/ */
@ -107,6 +117,7 @@ export class CoreSearchGlobalSearchResultsSource extends CorePaginatedItemsManag
*/ */
reset(): void { reset(): void {
this.pagesLoaded = 0; this.pagesLoaded = 0;
delete this.totalResults;
delete this.topResultsIds; delete this.topResultsIds;
super.reset(); super.reset();
@ -130,6 +141,8 @@ export class CoreSearchGlobalSearchResultsSource extends CorePaginatedItemsManag
const pageResults = await CoreSearchGlobalSearch.getResults(this.query, this.filters, page); 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))); results.push(...pageResults.results.filter(result => !this.topResultsIds?.includes(result.id)));
return { items: results, hasMoreItems: pageResults.canLoadMore }; return { items: results, hasMoreItems: pageResults.canLoadMore };

View File

@ -16,16 +16,19 @@ import { NgModule } from '@angular/core';
import { CoreSharedModule } from '@/core/shared.module'; import { CoreSharedModule } from '@/core/shared.module';
import { CoreSearchBoxComponent } from './search-box/search-box'; import { CoreSearchBoxComponent } from './search-box/search-box';
import { CoreSearchGlobalSearchResultComponent } from '@features/search/components/global-search-result/global-search-result';
@NgModule({ @NgModule({
declarations: [ declarations: [
CoreSearchBoxComponent, CoreSearchBoxComponent,
CoreSearchGlobalSearchResultComponent,
], ],
imports: [ imports: [
CoreSharedModule, CoreSharedModule,
], ],
exports: [ exports: [
CoreSearchBoxComponent, CoreSearchBoxComponent,
CoreSearchGlobalSearchResultComponent,
], ],
}) })
export class CoreSearchComponentsModule {} export class CoreSearchComponentsModule {}

View File

@ -11,14 +11,14 @@
<core-format-text [text]="result.title"></core-format-text> <core-format-text [text]="result.title"></core-format-text>
</h3> </h3>
<core-format-text *ngIf="result.content && !result.course && !result.user" [text]="result.content"></core-format-text> <core-format-text *ngIf="result.content && !result.course && !result.user" [text]="result.content"></core-format-text>
<div *ngIf="result.context" class="flex-row"> <div *ngIf="renderedContext" class="flex-row">
<div *ngIf="result.context.courseName" class="result-context"> <div *ngIf="renderedContext.courseName" class="result-context">
<ion-icon name="fas-graduation-cap" aria-hidden="true"></ion-icon> <ion-icon name="fas-graduation-cap" aria-hidden="true"></ion-icon>
<core-format-text [text]="result.context.courseName"></core-format-text> <core-format-text [text]="renderedContext.courseName"></core-format-text>
</div> </div>
<div *ngIf="result.context.userName" class="result-context"> <div *ngIf="renderedContext.userName" class="result-context">
<ion-icon name="fas-user" aria-hidden="true"></ion-icon> <ion-icon name="fas-user" aria-hidden="true"></ion-icon>
<span>{{ 'core.search.resultby' | translate: { $a: result.context.userName } }}</span> <span>{{ 'core.search.resultby' | translate: { $a: renderedContext.userName } }}</span>
</div> </div>
</div> </div>
</ion-label> </ion-label>

View File

@ -13,7 +13,7 @@
// limitations under the License. // limitations under the License.
import { Component, Input, Output, EventEmitter, OnChanges } from '@angular/core'; import { Component, Input, Output, EventEmitter, OnChanges } from '@angular/core';
import { CoreSearchGlobalSearchResult } from '@features/search/services/global-search'; import { CoreSearchGlobalSearchResult, CoreSearchGlobalSearchResultContext } from '@features/search/services/global-search';
@Component({ @Component({
selector: 'core-search-global-search-result', selector: 'core-search-global-search-result',
@ -23,6 +23,9 @@ import { CoreSearchGlobalSearchResult } from '@features/search/services/global-s
export class CoreSearchGlobalSearchResultComponent implements OnChanges { export class CoreSearchGlobalSearchResultComponent implements OnChanges {
@Input() result!: CoreSearchGlobalSearchResult; @Input() result!: CoreSearchGlobalSearchResult;
@Input() showCourse?: boolean;
renderedContext: CoreSearchGlobalSearchResultContext | null = null;
renderedIcon: string | null = null; renderedIcon: string | null = null;
@Output() onClick = new EventEmitter(); @Output() onClick = new EventEmitter();
@ -31,9 +34,25 @@ export class CoreSearchGlobalSearchResultComponent implements OnChanges {
* @inheritdoc * @inheritdoc
*/ */
ngOnChanges(): void { ngOnChanges(): void {
this.renderedContext = this.computeRenderedContext();
this.renderedIcon = this.computeRenderedIcon(); this.renderedIcon = this.computeRenderedIcon();
} }
/**
* Calculate the value of the context to render.
*
* @returns Rendered context.
*/
private computeRenderedContext(): CoreSearchGlobalSearchResultContext | null {
const context = { ...this.result.context } ?? {};
if (this.showCourse === false) {
delete context.courseName;
}
return Object.keys(context).length > 0 ? context : null;
}
/** /**
* Calculate the value of the icon to render. * Calculate the value of the icon to render.
* *

View File

@ -19,7 +19,6 @@ import { CoreSearchGlobalSearchPage } from './pages/global-search/global-search'
import { CoreSearchComponentsModule } from '@features/search/components/components.module'; import { CoreSearchComponentsModule } from '@features/search/components/components.module';
import { CoreMainMenuComponentsModule } from '@features/mainmenu/components/components.module'; import { CoreMainMenuComponentsModule } from '@features/mainmenu/components/components.module';
import { buildTabMainRoutes } from '@features/mainmenu/mainmenu-tab-routing.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. * Build module routes.
@ -42,7 +41,6 @@ function buildRoutes(injector: Injector): Routes {
exports: [RouterModule], exports: [RouterModule],
declarations: [ declarations: [
CoreSearchGlobalSearchPage, CoreSearchGlobalSearchPage,
CoreSearchGlobalSearchResultComponent,
], ],
providers: [ providers: [
{ {

View File

@ -78,7 +78,9 @@ export type CoreSearchGlobalSearchSearchArea = {
export interface CoreSearchGlobalSearchFilters { export interface CoreSearchGlobalSearchFilters {
searchAreaCategoryIds?: string[]; searchAreaCategoryIds?: string[];
searchAreaIds?: string[];
courseIds?: number[]; courseIds?: number[];
contextIds?: number[];
} }
/** /**
@ -116,10 +118,11 @@ export class CoreSearchGlobalSearchService {
query: string, query: string,
filters: CoreSearchGlobalSearchFilters, filters: CoreSearchGlobalSearchFilters,
page: number, page: number,
): Promise<{ results: CoreSearchGlobalSearchResult[]; canLoadMore: boolean }> { ): Promise<{ results: CoreSearchGlobalSearchResult[]; total: number; canLoadMore: boolean }> {
if (this.filtersYieldEmptyResults(filters)) { if (this.filtersYieldEmptyResults(filters)) {
return { return {
results: [], results: [],
total: 0,
canLoadMore: false, canLoadMore: false,
}; };
} }
@ -128,7 +131,7 @@ export class CoreSearchGlobalSearchService {
const params: CoreSearchGetResultsWSParams = { const params: CoreSearchGetResultsWSParams = {
query, query,
page, page,
filters: await this.prepareWSFilters(filters), filters: await this.prepareAdvancedWSFilters(filters),
}; };
const preSets = CoreSites.getReadingStrategyPreSets(CoreSitesReadingStrategy.PREFER_NETWORK); const preSets = CoreSites.getReadingStrategyPreSets(CoreSitesReadingStrategy.PREFER_NETWORK);
@ -136,6 +139,7 @@ export class CoreSearchGlobalSearchService {
return { return {
results: await Promise.all((results ?? []).map(result => this.formatWSResult(result))), results: await Promise.all((results ?? []).map(result => this.formatWSResult(result))),
total: totalcount,
canLoadMore: totalcount > (page + 1) * CORE_SEARCH_GLOBAL_SEARCH_PAGE_LENGTH, canLoadMore: totalcount > (page + 1) * CORE_SEARCH_GLOBAL_SEARCH_PAGE_LENGTH,
}; };
} }
@ -155,7 +159,7 @@ export class CoreSearchGlobalSearchService {
const site = CoreSites.getRequiredCurrentSite(); const site = CoreSites.getRequiredCurrentSite();
const params: CoreSearchGetTopResultsWSParams = { const params: CoreSearchGetTopResultsWSParams = {
query, query,
filters: await this.prepareWSFilters(filters), filters: await this.prepareAdvancedWSFilters(filters),
}; };
const preSets = CoreSites.getReadingStrategyPreSets(CoreSitesReadingStrategy.PREFER_NETWORK); const preSets = CoreSites.getReadingStrategyPreSets(CoreSitesReadingStrategy.PREFER_NETWORK);
@ -207,7 +211,7 @@ export class CoreSearchGlobalSearchService {
const site = CoreSites.getRequiredCurrentSite(); const site = CoreSites.getRequiredCurrentSite();
const params: CoreSearchViewResultsWSParams = { const params: CoreSearchViewResultsWSParams = {
query, query,
filters: await this.prepareWSFilters(filters), filters: await this.prepareBasicWSFilters(filters),
}; };
await site.write<CoreSearchViewResultsWSResponse>('core_search_view_results', params); await site.write<CoreSearchViewResultsWSResponse>('core_search_view_results', params);
@ -269,33 +273,62 @@ export class CoreSearchGlobalSearchService {
* @returns Whether the given filters will return 0 results. * @returns Whether the given filters will return 0 results.
*/ */
protected filtersYieldEmptyResults(filters: CoreSearchGlobalSearchFilters): boolean { protected filtersYieldEmptyResults(filters: CoreSearchGlobalSearchFilters): boolean {
return filters.courseIds?.length === 0 || filters.searchAreaCategoryIds?.length === 0; return filters.courseIds?.length === 0
|| filters.contextIds?.length === 0
|| filters.searchAreaIds?.length === 0
|| filters.searchAreaCategoryIds?.length === 0;
} }
/** /**
* Prepare search filters before sending to WS. * Prepare basic search filters before sending to WS.
* *
* @param filters App filters. * @param filters App filters.
* @returns WS filters. * @returns Basic WS filters.
*/ */
protected async prepareWSFilters(filters: CoreSearchGlobalSearchFilters): Promise<CoreSearchBasicWSFilters> { protected async prepareBasicWSFilters(filters: CoreSearchGlobalSearchFilters): Promise<CoreSearchBasicWSFilters> {
const wsFilters: CoreSearchBasicWSFilters = {}; const wsFilters: CoreSearchBasicWSFilters = {};
if (filters.courseIds) { if (filters.courseIds) {
wsFilters.courseids = filters.courseIds; wsFilters.courseids = filters.courseIds;
} }
if (filters.searchAreaIds) {
wsFilters.areaids = filters.searchAreaIds;
}
if (filters.searchAreaCategoryIds) { if (filters.searchAreaCategoryIds) {
const searchAreas = await this.getSearchAreas(); const searchAreas = await this.getSearchAreas();
wsFilters.areaids = searchAreas 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); .map(({ id }) => id);
} }
return wsFilters; return wsFilters;
} }
/**
* Prepare advanced search filters before sending to WS.
*
* @param filters App filters.
* @returns Advanced WS filters.
*/
protected async prepareAdvancedWSFilters(filters: CoreSearchGlobalSearchFilters): Promise<CoreSearchAdvancedWSFilters> {
const wsFilters: CoreSearchAdvancedWSFilters = await this.prepareBasicWSFilters(filters);
if (filters.contextIds) {
wsFilters.contextids = filters.contextIds;
}
return wsFilters;
}
} }
export const CoreSearchGlobalSearch = makeSingleton(CoreSearchGlobalSearchService); export const CoreSearchGlobalSearch = makeSingleton(CoreSearchGlobalSearchService);

View File

@ -37,6 +37,7 @@ interface Args {
module: 'forum-activity' | 'forum-post' | 'assign' | 'none'; module: 'forum-activity' | 'forum-post' | 'assign' | 'none';
courseContext: boolean; courseContext: boolean;
userContext: boolean; userContext: boolean;
showCourse: boolean;
} }
export default <Meta<Args>> { export default <Meta<Args>> {
@ -79,6 +80,7 @@ export default <Meta<Args>> {
module: 'none', module: 'none',
courseContext: false, courseContext: false,
userContext: false, userContext: false,
showCourse: true,
}, },
parameters: { parameters: {
design: { design: {
@ -88,7 +90,7 @@ export default <Meta<Args>> {
}, },
}; };
const Template = story<Args>(({ image, courseContext, userContext, module, ...args }) => { const Template = story<Args>(({ image, courseContext, userContext, module, showCourse, ...args }) => {
const result: CoreSearchGlobalSearchResult = { const result: CoreSearchGlobalSearchResult = {
...args, ...args,
id: 1, id: 1,
@ -126,7 +128,7 @@ const Template = story<Args>(({ image, courseContext, userContext, module, ...ar
return { return {
component: CoreSearchGlobalSearchResultComponent, component: CoreSearchGlobalSearchResultComponent,
props: { result }, props: { result, showCourse },
}; };
}); });