MOBILE-3371 search: Implement global search
This commit is contained in:
		
							parent
							
								
									c11fd99a0a
								
							
						
					
					
						commit
						65a4cc98f7
					
				@ -2310,6 +2310,10 @@
 | 
				
			|||||||
  "core.scanqr": "local_moodlemobileapp",
 | 
					  "core.scanqr": "local_moodlemobileapp",
 | 
				
			||||||
  "core.scrollbackward": "local_moodlemobileapp",
 | 
					  "core.scrollbackward": "local_moodlemobileapp",
 | 
				
			||||||
  "core.scrollforward": "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.resultby": "local_moodlemobileapp",
 | 
				
			||||||
  "core.search": "moodle",
 | 
					  "core.search": "moodle",
 | 
				
			||||||
  "core.searching": "local_moodlemobileapp",
 | 
					  "core.searching": "local_moodlemobileapp",
 | 
				
			||||||
 | 
				
			|||||||
@ -97,6 +97,7 @@ export abstract class CoreItemsManagerSource<Item = unknown> {
 | 
				
			|||||||
    reset(): void {
 | 
					    reset(): void {
 | 
				
			||||||
        this.items = null;
 | 
					        this.items = null;
 | 
				
			||||||
        this.dirty = false;
 | 
					        this.dirty = false;
 | 
				
			||||||
 | 
					        this.loaded = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        this.listeners.forEach(listener => listener.onReset?.call(listener));
 | 
					        this.listeners.forEach(listener => listener.onReset?.call(listener));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
				
			|||||||
@ -2747,6 +2747,8 @@ export const enum CoreSiteConfigSupportAvailability {
 | 
				
			|||||||
 */
 | 
					 */
 | 
				
			||||||
export type CoreSiteConfig = Record<string, string> & {
 | 
					export type CoreSiteConfig = Record<string, string> & {
 | 
				
			||||||
    supportavailability?: string; // String representation of CoreSiteConfigSupportAvailability.
 | 
					    supportavailability?: string; // String representation of CoreSiteConfigSupportAvailability.
 | 
				
			||||||
 | 
					    searchbanner?: string; // Search banner text.
 | 
				
			||||||
 | 
					    searchbannerenable?: string; // Whether search banner is enabled.
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										145
									
								
								src/core/features/search/classes/global-search-results-source.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										145
									
								
								src/core/features/search/classes/global-search-results-source.ts
									
									
									
									
									
										Normal file
									
								
							@ -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 { 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 {}
 | 
				
			||||||
 | 
				
			|||||||
@ -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}}"
 | 
					    "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>
 | 
				
			||||||
							
								
								
									
										115
									
								
								src/core/features/search/pages/global-search/global-search.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										115
									
								
								src/core/features/search/pages/global-search/global-search.ts
									
									
									
									
									
										Normal file
									
								
							@ -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();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										56
									
								
								src/core/features/search/search-lazy.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								src/core/features/search/search-lazy.module.ts
									
									
									
									
									
										Normal file
									
								
							@ -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
 | 
					// See the License for the specific language governing permissions and
 | 
				
			||||||
// limitations under the License.
 | 
					// 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';
 | 
					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>[] = [
 | 
					export const CORE_SEARCH_SERVICES: Type<unknown>[] = [
 | 
				
			||||||
    CoreSearchHistoryProvider,
 | 
					    CoreSearchHistoryProvider,
 | 
				
			||||||
 | 
					    CoreSearchGlobalSearchService,
 | 
				
			||||||
 | 
					];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const mainMenuChildrenRoutes: Routes = [
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        path: CORE_SEARCH_PAGE_NAME,
 | 
				
			||||||
 | 
					        loadChildren: () => import('./search-lazy.module').then(m => m.CoreSearchLazyModule),
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
];
 | 
					];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@NgModule({
 | 
					@NgModule({
 | 
				
			||||||
    imports: [
 | 
					    imports: [
 | 
				
			||||||
        CoreSearchComponentsModule,
 | 
					        CoreSearchComponentsModule,
 | 
				
			||||||
 | 
					        CoreMainMenuTabRoutingModule.forChild(mainMenuChildrenRoutes),
 | 
				
			||||||
 | 
					        CoreMainMenuRoutingModule.forChild({ children: mainMenuChildrenRoutes }),
 | 
				
			||||||
    ],
 | 
					    ],
 | 
				
			||||||
    providers: [
 | 
					    providers: [
 | 
				
			||||||
        { provide: CORE_SITE_SCHEMAS, useValue: [SITE_SCHEMA], multi: true },
 | 
					        { provide: CORE_SITE_SCHEMAS, useValue: [SITE_SCHEMA], multi: true },
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            provide: APP_INITIALIZER,
 | 
				
			||||||
 | 
					            multi: true,
 | 
				
			||||||
 | 
					            useValue() {
 | 
				
			||||||
 | 
					                CoreMainMenuDelegate.registerHandler(CoreSearchMainMenuHandler.instance);
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
    ],
 | 
					    ],
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
export class CoreSearchModule {}
 | 
					export class CoreSearchModule {}
 | 
				
			||||||
 | 
				
			|||||||
@ -12,8 +12,15 @@
 | 
				
			|||||||
// See the License for the specific language governing permissions and
 | 
					// See the License for the specific language governing permissions and
 | 
				
			||||||
// limitations under the License.
 | 
					// 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 { 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 = {
 | 
					export type CoreSearchGlobalSearchResult = {
 | 
				
			||||||
    id: number;
 | 
					    id: number;
 | 
				
			||||||
@ -36,3 +43,300 @@ export type CoreSearchGlobalSearchResultModule = {
 | 
				
			|||||||
    iconurl: string;
 | 
					    iconurl: string;
 | 
				
			||||||
    area: 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[];
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										56
									
								
								src/core/features/search/services/handlers/mainmenu.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								src/core/features/search/services/handlers/mainmenu.ts
									
									
									
									
									
										Normal file
									
								
							@ -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);
 | 
				
			||||||
							
								
								
									
										110
									
								
								src/core/features/search/tests/behat/global-search.feature
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								src/core/features/search/tests/behat/global-search.feature
									
									
									
									
									
										Normal file
									
								
							@ -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;
 | 
					        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.
 | 
					     * Show a modal warning the user that he should use a different app.
 | 
				
			||||||
     *
 | 
					     *
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user