Skip to content

Commit

Permalink
MOBILE-3371 search: Implement global search
Browse files Browse the repository at this point in the history
  • Loading branch information
NoelDeMartin committed Jun 28, 2023
1 parent eaeb6db commit 5e08921
Show file tree
Hide file tree
Showing 13 changed files with 860 additions and 5 deletions.
4 changes: 4 additions & 0 deletions scripts/langindex.json
Original file line number Diff line number Diff line change
Expand Up @@ -2299,6 +2299,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",
Expand Down
2 changes: 2 additions & 0 deletions src/core/classes/site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2746,6 +2746,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.
};

/**
Expand Down
145 changes: 145 additions & 0 deletions src/core/features/search/classes/global-search-results-source.ts
Original file line number Diff line number Diff line change
@@ -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;
}

}
3 changes: 0 additions & 3 deletions src/core/features/search/components/components.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
4 changes: 4 additions & 0 deletions src/core/features/search/lang.json
Original file line number Diff line number Diff line change
@@ -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}}"
}
46 changes: 46 additions & 0 deletions src/core/features/search/pages/global-search/global-search.html
Original file line number Diff line number Diff line change
@@ -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.courses.search' | translate"
[searchLabel]="'core.search' | translate" [autoFocus]="true" searchArea="CoreSearchGlobalSearch"></core-search-box>

<ion-list *ngIf="this.resultsSource.isLoaded()">
<core-search-global-search-result *ngFor="let result of this.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="this.resultsSource.isEmpty()" icon="fas-magnifying-glass" [dimmed]="!this.resultsSource.isLoaded()">
<p *ngIf="!this.resultsSource.isLoaded()">{{ 'core.search.empty' | translate }}</p>
<ng-container *ngIf="this.resultsSource.isLoaded()">
<p><strong>{{ 'core.search.noresults' | translate: { $a: this.resultsSource.getQuery() } }}</strong></p>
<p><small>{{ 'core.search.noresultshelp' | translate }}</small></p>
</ng-container>
</core-empty-box>
</div>
</ion-content>
96 changes: 96 additions & 0 deletions src/core/features/search/pages/global-search/global-search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// (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';

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

/**
* Clear search results.
*/
clearSearch(): void {
this.loadMoreError = null;
}

/**
* Visit a result's origin.
*
* @param result Result to visit.
*/
async visitResult(result: CoreSearchGlobalSearchResult): Promise<void> {
await CoreContentLinksHelper.handleLink(result.url);
}

/**
* Load more results.
*
* @param complete Notify completion.
*/
async loadMoreResults(complete: () => void ): Promise<void> {
try {
await this.resultsSource?.load();
} catch (error) {
this.loadMoreError = CoreDomUtils.getErrorMessage(error);
} finally {
complete();
}
}

}
56 changes: 56 additions & 0 deletions src/core/features/search/search-lazy.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
Loading

0 comments on commit 5e08921

Please sign in to comment.