MOBILE-4207 forum: Implement search forums block

main
Noel De Martin 2023-07-18 16:59:40 +09:00
parent 596365da9c
commit cd85155953
16 changed files with 425 additions and 5 deletions

View File

@ -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",

View File

@ -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,

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

@ -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,7 +52,13 @@ export const ADDON_MOD_FORUM_SERVICES: Type<unknown>[] = [
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),

View File

@ -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}}.",

View File

@ -0,0 +1,50 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>
<h1>{{ 'addon.block_searchforums.pluginname' | translate }}</h1>
</ion-title>
<ion-buttons slot="end">
<core-user-menu-button></core-user-menu-button>
</ion-buttons>
</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,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<void> {
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<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,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

View File

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

View File

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

View File

@ -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: [
{

View File

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