MOBILE-3371 search: Implement global search
parent
c11fd99a0a
commit
65a4cc98f7
|
@ -2310,6 +2310,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",
|
||||
|
|
|
@ -97,6 +97,7 @@ export abstract class CoreItemsManagerSource<Item = unknown> {
|
|||
reset(): void {
|
||||
this.items = null;
|
||||
this.dirty = false;
|
||||
this.loaded = false;
|
||||
|
||||
this.listeners.forEach(listener => listener.onReset?.call(listener));
|
||||
}
|
||||
|
|
|
@ -2747,6 +2747,8 @@ export const enum CoreSiteConfigSupportAvailability {
|
|||
*/
|
||||
export type CoreSiteConfig = Record<string, string> & {
|
||||
supportavailability?: string; // String representation of CoreSiteConfigSupportAvailability.
|
||||
searchbanner?: string; // Search banner text.
|
||||
searchbannerenable?: string; // Whether search banner is enabled.
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -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<CoreSearchGlobalSearchResult> {
|
||||
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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 {}
|
||||
|
|
|
@ -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}}"
|
||||
}
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button [text]="'core.back' | translate"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>
|
||||
<h1>{{ 'core.search.globalsearch' | 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="CoreSearchGlobalSearch"></core-search-box>
|
||||
|
||||
<ion-list *ngIf="resultsSource.isLoaded()">
|
||||
<core-search-global-search-result *ngFor="let result of resultsSource.getItems()" [result]="result"
|
||||
(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>
|
|
@ -0,0 +1,115 @@
|
|||
// (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';
|
||||
import { CoreAnalytics, CoreAnalyticsEventType } from '@services/analytics';
|
||||
import { Translate } from '@singletons';
|
||||
import { CoreUrlUtils } from '@services/utils/url';
|
||||
|
||||
@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<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()),
|
||||
);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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 {}
|
|
@ -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<unknown>[] = [
|
||||
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 {}
|
||||
|
|
|
@ -12,8 +12,15 @@
|
|||
// 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';
|
||||
|
||||
export const CORE_SEARCH_GLOBAL_SEARCH_PAGE_LENGTH = 10;
|
||||
|
||||
export type CoreSearchGlobalSearchResult = {
|
||||
id: number;
|
||||
|
@ -36,3 +43,300 @@ export type CoreSearchGlobalSearchResultModule = {
|
|||
iconurl: string;
|
||||
area: 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.PREFER_NETWORK);
|
||||
|
||||
const { totalcount, results } = await site.read<CoreSearchGetResultsWSResponse>('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<CoreSearchGlobalSearchResult[]> {
|
||||
const site = CoreSites.getRequiredCurrentSite();
|
||||
const params: CoreSearchGetTopResultsWSParams = {
|
||||
query,
|
||||
filters: await this.prepareWSFilters(filters),
|
||||
};
|
||||
const preSets = CoreSites.getReadingStrategyPreSets(CoreSitesReadingStrategy.PREFER_NETWORK);
|
||||
|
||||
const { results } = await site.read<CoreSearchGetTopResultsWSResponse>('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<CoreSearchGlobalSearchSearchArea[]> {
|
||||
const site = CoreSites.getRequiredCurrentSite();
|
||||
const params: CoreSearchGetSearchAreasListWSParams = {};
|
||||
|
||||
const { areas } = await site.read<CoreSearchGetSearchAreasListWSResponse>('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<void> {
|
||||
const site = CoreSites.getRequiredCurrentSite();
|
||||
const params: CoreSearchViewResultsWSParams = {
|
||||
query,
|
||||
filters: await this.prepareWSFilters(filters),
|
||||
};
|
||||
|
||||
await site.write<CoreSearchViewResultsWSResponse>('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<CoreSearchGlobalSearchResult> {
|
||||
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.substring(4),
|
||||
iconurl: wsResult.iconurl,
|
||||
area: wsResult.areaname,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare search filters before sending to WS.
|
||||
*
|
||||
* @param filters App filters.
|
||||
* @returns WS filters.
|
||||
*/
|
||||
protected async prepareWSFilters(filters: CoreSearchGlobalSearchFilters): Promise<CoreSearchBasicWSFilters> {
|
||||
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[];
|
||||
};
|
||||
|
|
|
@ -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<boolean> {
|
||||
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);
|
|
@ -0,0 +1,110 @@
|
|||
@core @core_search @app @javascript @lms_from4.3
|
||||
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
|
||||
|
||||
When I press the back button in the app
|
||||
And I press "Clear search" in the app
|
||||
Then I should find "What are you searching for?" in the app
|
||||
But I should not find "Test forum" in the app
|
||||
|
||||
Given global search expects the query "noresults" and will return:
|
||||
| type | idnumber |
|
||||
And I set the field "Search" to "noresults" in the app
|
||||
And I press "Search" "button" in the app
|
||||
Then I should find "No results for" 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
|
|
@ -1506,6 +1506,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<T>(text: string, needsTranslate: boolean, operation: () => Promise<T>): Promise<T | null> {
|
||||
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.
|
||||
*
|
||||
|
|
Loading…
Reference in New Issue