MOBILE-3371 search: Filter global search

main
Noel De Martin 2023-06-28 17:23:30 +02:00
parent 65a4cc98f7
commit 6cf958eb8a
11 changed files with 522 additions and 5 deletions

View File

@ -7,7 +7,7 @@
"",
"@Component({",
" selector: '$2${TM_FILENAME_BASE}',",
" templateUrl: '$2${TM_FILENAME_BASE}.html',",
" templateUrl: '${TM_FILENAME_BASE}.html',",
"})",
"export class ${1:${TM_FILENAME_BASE}}Component {",
"",
@ -110,6 +110,24 @@
],
"description": "[Moodle] Create a Pure Singleton"
},
"[Moodle] Events": {
"prefix": "maeventsdeclaration",
"body": [
"declare module '@singletons/events' {",
"",
" /**",
" * Augment CoreEventsData interface with events specific to this service.",
" *",
" * @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation",
" */",
" export interface CoreEventsData {",
" [$1]: $2;",
" }",
"",
"}"
],
"description": ""
},
"Innherit doc": {
"prefix": "inheritdoc",
"body": [

View File

@ -2310,7 +2310,12 @@
"core.scanqr": "local_moodlemobileapp",
"core.scrollbackward": "local_moodlemobileapp",
"core.scrollforward": "local_moodlemobileapp",
"core.search.allcourses": "search",
"core.search.allcategories": "local_moodlemobileapp",
"core.search.empty": "local_moodlemobileapp",
"core.search.filtercategories": "local_moodlemobileapp",
"core.search.filtercourses": "local_moodlemobileapp",
"core.search.filterheader": "search",
"core.search.globalsearch": "search",
"core.search.noresults": "local_moodlemobileapp",
"core.search.noresultshelp": "local_moodlemobileapp",

View File

@ -0,0 +1,267 @@
// (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, Input } from '@angular/core';
import { CoreEnrolledCourseData, CoreCourses } from '@features/courses/services/courses';
import {
CoreSearchGlobalSearchFilters,
CoreSearchGlobalSearch,
CoreSearchGlobalSearchSearchAreaCategory,
CORE_SEARCH_GLOBAL_SEARCH_FILTERS_UPDATED,
} from '@features/search/services/global-search';
import { CoreEvents } from '@singletons/events';
import { ModalController } from '@singletons';
import { IonRefresher } from '@ionic/angular';
import { CoreUtils } from '@services/utils/utils';
type Filter<T=unknown> = T & { checked: boolean };
@Component({
selector: 'core-search-global-search-filters',
templateUrl: 'global-search-filters.html',
styleUrls: ['./global-search-filters.scss'],
})
export class CoreSearchGlobalSearchFiltersComponent implements OnInit {
allSearchAreaCategories: boolean | null = true;
searchAreaCategories: Filter<CoreSearchGlobalSearchSearchAreaCategory>[] = [];
allCourses: boolean | null = true;
courses: Filter<CoreEnrolledCourseData>[] = [];
@Input() filters?: CoreSearchGlobalSearchFilters;
private newFilters: CoreSearchGlobalSearchFilters = {};
/**
* @inheritdoc
*/
async ngOnInit(): Promise<void> {
this.newFilters = this.filters ?? {};
await this.updateSearchAreaCategories();
await this.updateCourses();
}
/**
* Close popover.
*/
close(): void {
ModalController.dismiss();
}
/**
* Checkbox for all search area categories has been updated.
*/
allSearchAreaCategoriesUpdated(): void {
if (this.allSearchAreaCategories === null) {
return;
}
const checked = this.allSearchAreaCategories;
this.searchAreaCategories.forEach(searchAreaCategory => {
if (searchAreaCategory.checked === checked) {
return;
}
searchAreaCategory.checked = checked;
});
}
/**
* Checkbox for one search area category has been updated.
*
* @param searchAreaCategory Filter status.
*/
onSearchAreaCategoryInputChanged(searchAreaCategory: Filter<CoreSearchGlobalSearchSearchAreaCategory>): void {
if (
!searchAreaCategory.checked &&
this.newFilters.searchAreaCategoryIds &&
!this.newFilters.searchAreaCategoryIds.includes(searchAreaCategory.id)
) {
return;
}
if (
searchAreaCategory.checked &&
(!this.newFilters.searchAreaCategoryIds || this.newFilters.searchAreaCategoryIds.includes(searchAreaCategory.id))
) {
return;
}
this.searchAreaCategoryUpdated();
}
/**
* Checkbox for all courses has been updated.
*/
allCoursesUpdated(): void {
if (this.allCourses === null) {
return;
}
const checked = this.allCourses;
this.courses.forEach(course => {
if (course.checked === checked) {
return;
}
course.checked = checked;
});
}
/**
* Checkbox for one course has been updated.
*
* @param course Filter status.
*/
onCourseInputChanged(course: Filter<CoreEnrolledCourseData>): void {
if (!course.checked && this.newFilters.courseIds && !this.newFilters.courseIds.includes(course.id)) {
return;
}
if (course.checked && (!this.newFilters.courseIds || this.newFilters.courseIds.includes(course.id))) {
return;
}
this.courseUpdated();
}
/**
* Refresh filters.
*
* @param refresher Refresher.
*/
async refreshFilters(refresher?: IonRefresher): Promise<void> {
await CoreUtils.ignoreErrors(Promise.all([
CoreSearchGlobalSearch.invalidateSearchAreas(),
CoreCourses.invalidateUserCourses(),
]));
await this.updateSearchAreaCategories();
await this.updateCourses();
refresher?.complete();
}
/**
* Update search area categories.
*/
private async updateSearchAreaCategories(): Promise<void> {
const searchAreas = await CoreSearchGlobalSearch.getSearchAreas();
const searchAreaCategoryIds = new Set();
this.searchAreaCategories = [];
for (const searchArea of searchAreas) {
if (searchAreaCategoryIds.has(searchArea.category.id)) {
continue;
}
searchAreaCategoryIds.add(searchArea.category.id);
this.searchAreaCategories.push({
...searchArea.category,
checked: this.filters?.searchAreaCategoryIds?.includes(searchArea.category.id) ?? true,
});
}
this.allSearchAreaCategories = this.getGroupFilterStatus(this.searchAreaCategories);
}
/**
* Update courses.
*/
private async updateCourses(): Promise<void> {
const courses = await CoreCourses.getUserCourses();
this.courses = courses
.sort((a, b) => (a.shortname?.toLowerCase() ?? '').localeCompare(b.shortname?.toLowerCase() ?? ''))
.map(course => ({
...course,
checked: this.filters?.courseIds?.includes(course.id) ?? true,
}));
this.allCourses = this.getGroupFilterStatus(this.courses);
}
/**
* Checkbox for one search area category has been updated.
*/
private searchAreaCategoryUpdated(): void {
const filterStatus = this.getGroupFilterStatus(this.searchAreaCategories);
if (filterStatus !== this.allSearchAreaCategories) {
this.allSearchAreaCategories = filterStatus;
}
this.emitFiltersUpdated();
}
/**
* Course filter status has been updated.
*/
private courseUpdated(): void {
const filterStatus = this.getGroupFilterStatus(this.courses);
if (filterStatus !== this.allCourses) {
this.allCourses = filterStatus;
}
this.emitFiltersUpdated();
}
/**
* Get the status for a filter representing a group of filters.
*
* @param filters Filters in the group.
* @returns Group filter status. This will be true if all filters are checked, false if all filters are unchecked,
* or null if filters have mixed states.
*/
private getGroupFilterStatus(filters: Filter[]): boolean | null {
if (filters.length === 0) {
return null;
}
const firstChecked = filters[0].checked;
for (const filter of filters) {
if (filter.checked === firstChecked) {
continue;
}
return null;
}
return firstChecked;
}
/**
* Emit filters updated event.
*/
private emitFiltersUpdated(): void {
this.newFilters = {};
if (!this.allSearchAreaCategories) {
this.newFilters.searchAreaCategoryIds = this.searchAreaCategories.filter(({ checked }) => checked).map(({ id }) => id);
}
if (!this.allCourses) {
this.newFilters.courseIds = this.courses.filter(({ checked }) => checked).map(({ id }) => id);
}
CoreEvents.trigger(CORE_SEARCH_GLOBAL_SEARCH_FILTERS_UPDATED, this.newFilters);
}
}

View File

@ -0,0 +1,58 @@
<ion-header>
<ion-toolbar>
<ion-title>
<h1>{{ 'core.search.filterheader' | translate }}</h1>
</ion-title>
<ion-buttons slot="end">
<ion-button fill="clear" (click)="close()" [attr.aria-label]="'core.close' | translate">
<ion-icon name="fas-xmark" slot="icon-only" aria-hidden=true></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content [fullscreen]="true">
<ion-refresher slot="fixed" (ionRefresh)="refreshFilters($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<ion-list>
<ng-container *ngIf="searchAreaCategories.length > 0">
<core-spacer></core-spacer>
<ion-item class="ion-text-wrap help">
<ion-label>
{{ 'core.search.filtercategories' | translate }}
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>{{ 'core.search.allcategories' | translate }}</ion-label>
<ion-checkbox slot="end" [(ngModel)]="allSearchAreaCategories" [indeterminate]="allSearchAreaCategories === null"
(ionChange)="allSearchAreaCategoriesUpdated()"></ion-checkbox>
</ion-item>
<ion-item class="ion-text-wrap" *ngFor="let searchAreaCategory of searchAreaCategories">
<ion-label>
<core-format-text [text]="searchAreaCategory.name"></core-format-text>
</ion-label>
<ion-checkbox slot="end" [(ngModel)]="searchAreaCategory.checked"
(ionChange)="onSearchAreaCategoryInputChanged(searchAreaCategory)"></ion-checkbox>
</ion-item>
</ng-container>
<ng-container *ngIf="courses.length > 0">
<core-spacer></core-spacer>
<ion-item class="ion-text-wrap help">
<ion-label>
{{ 'core.search.filtercourses' | translate }}
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>{{ 'core.search.allcourses' | translate }}</ion-label>
<ion-checkbox slot="end" [(ngModel)]="allCourses" [indeterminate]="allCourses === null" (ionChange)="allCoursesUpdated()">
</ion-checkbox>
</ion-item>
<ion-item class="ion-text-wrap" *ngFor="let course of courses">
<ion-label>
<core-format-text [text]="course.shortname"></core-format-text>
</ion-label>
<ion-checkbox slot="end" [(ngModel)]="course.checked" (ionChange)="onCourseInputChanged(course)"></ion-checkbox>
</ion-item>
</ng-container>
</ion-list>
</ion-content>

View File

@ -0,0 +1,30 @@
// (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 } from '@angular/core';
import { CoreSearchGlobalSearchFiltersComponent } from './global-search-filters.component';
export { CoreSearchGlobalSearchFiltersComponent };
@NgModule({
imports: [
CoreSharedModule,
],
declarations: [
CoreSearchGlobalSearchFiltersComponent,
],
})
export class CoreSearchGlobalSearchFiltersComponentModule {}

View File

@ -0,0 +1,21 @@
:host {
--help-text-color: var(--gray-700);
ion-item.help {
color: var(--help-text-color);
ion-label {
margin-bottom: 0;
}
}
ion-item:not(.help) {
font-size: 16px;
}
}
:host-context(html.dark) {
--help-text-color: var(--gray-400);
}

View File

@ -1,5 +1,10 @@
{
"allcourses": "All courses",
"allcategories": "All categories",
"empty": "What are you searching for?",
"filtercategories": "Filter results by",
"filtercourses": "Search in",
"filterheader": "Filter",
"globalsearch": "Global search",
"noresults": "No results for \"{{$a}}\"",
"noresultshelp": "Check for typos or try using different keywords",

View File

@ -42,5 +42,11 @@
<p><small>{{ 'core.search.noresultshelp' | translate }}</small></p>
</ng-container>
</core-empty-box>
<ion-fab slot="fixed" core-fab vertical="bottom" horizontal="end">
<ion-fab-button (click)="openFilters()" [attr.aria-label]="'core.filter' | translate">
<ion-icon name="fas-filter" aria-hidden="true"></ion-icon>
</ion-fab-button>
</ion-fab>
</div>
</ion-content>

View File

@ -12,26 +12,33 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnInit } from '@angular/core';
import { Component, OnInit, OnDestroy } 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';
import { CoreEvents, CoreEventObserver } from '@singletons/events';
import {
CoreSearchGlobalSearchResult,
CoreSearchGlobalSearchFilters,
CORE_SEARCH_GLOBAL_SEARCH_FILTERS_UPDATED,
CoreSearchGlobalSearch,
} from '@features/search/services/global-search';
@Component({
selector: 'page-core-search-global-search',
templateUrl: 'global-search.html',
})
export class CoreSearchGlobalSearchPage implements OnInit {
export class CoreSearchGlobalSearchPage implements OnInit, OnDestroy {
loadMoreError: string | null = null;
searchBanner: string | null = null;
resultsSource = new CoreSearchGlobalSearchResultsSource('', {});
private filtersObserver?: CoreEventObserver;
/**
* @inheritdoc
@ -43,6 +50,18 @@ export class CoreSearchGlobalSearchPage implements OnInit {
if (CoreUtils.isTrueOrOne(site.config?.searchbannerenable) && searchBanner.length > 0) {
this.searchBanner = searchBanner;
}
this.filtersObserver = CoreEvents.on(
CORE_SEARCH_GLOBAL_SEARCH_FILTERS_UPDATED,
filters => this.resultsSource.setFilters(filters),
);
}
/**
* @inheritdoc
*/
ngOnDestroy(): void {
this.filtersObserver?.off();
}
/**
@ -88,6 +107,23 @@ export class CoreSearchGlobalSearchPage implements OnInit {
this.resultsSource.reset();
}
/**
* Open filters.
*/
async openFilters(): Promise<void> {
const { CoreSearchGlobalSearchFiltersComponent } =
await import('@features/search/components/global-search-filters/global-search-filters.module');
await CoreDomUtils.openSideModal<CoreSearchGlobalSearchFilters>({
component: CoreSearchGlobalSearchFiltersComponent,
componentProps: { filters: this.resultsSource.getFilters() },
});
if (!this.resultsSource.hasEmptyQuery() && this.resultsSource.isDirty()) {
await CoreDomUtils.showOperationModals('core.searching', true, () => this.resultsSource.reload());
}
}
/**
* Visit a result's origin.
*

View File

@ -19,8 +19,23 @@ 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';
import { CoreSite } from '@classes/site';
declare module '@singletons/events' {
/**
* Augment CoreEventsData interface with events specific to this service.
*
* @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation
*/
export interface CoreEventsData {
[CORE_SEARCH_GLOBAL_SEARCH_FILTERS_UPDATED]: CoreSearchGlobalSearchFilters;
}
}
export const CORE_SEARCH_GLOBAL_SEARCH_PAGE_LENGTH = 10;
export const CORE_SEARCH_GLOBAL_SEARCH_FILTERS_UPDATED = 'core-search-global-search-filters-updated';
export type CoreSearchGlobalSearchResult = {
id: number;
@ -66,6 +81,8 @@ export interface CoreSearchGlobalSearchFilters {
@Injectable({ providedIn: 'root' })
export class CoreSearchGlobalSearchService {
private static readonly SEARCH_AREAS_CACHE_KEY = 'CoreSearchGlobalSearch:SearchAreas';
/**
* Get results.
*
@ -79,6 +96,13 @@ export class CoreSearchGlobalSearchService {
filters: CoreSearchGlobalSearchFilters,
page: number,
): Promise<{ results: CoreSearchGlobalSearchResult[]; canLoadMore: boolean }> {
if (this.filtersYieldEmptyResults(filters)) {
return {
results: [],
canLoadMore: false,
};
}
const site = CoreSites.getRequiredCurrentSite();
const params: CoreSearchGetResultsWSParams = {
query,
@ -103,6 +127,10 @@ export class CoreSearchGlobalSearchService {
* @returns Top search results.
*/
async getTopResults(query: string, filters: CoreSearchGlobalSearchFilters): Promise<CoreSearchGlobalSearchResult[]> {
if (this.filtersYieldEmptyResults(filters)) {
return [];
}
const site = CoreSites.getRequiredCurrentSite();
const params: CoreSearchGetTopResultsWSParams = {
query,
@ -124,7 +152,10 @@ export class CoreSearchGlobalSearchService {
const site = CoreSites.getRequiredCurrentSite();
const params: CoreSearchGetSearchAreasListWSParams = {};
const { areas } = await site.read<CoreSearchGetSearchAreasListWSResponse>('core_search_get_search_areas_list', params);
const { areas } = await site.read<CoreSearchGetSearchAreasListWSResponse>('core_search_get_search_areas_list', params, {
updateFrequency: CoreSite.FREQUENCY_RARELY,
cacheKey: CoreSearchGlobalSearchService.SEARCH_AREAS_CACHE_KEY,
});
return areas.map(area => ({
id: area.id,
@ -136,6 +167,15 @@ export class CoreSearchGlobalSearchService {
}));
}
/**
* Invalidate search areas cache.
*/
async invalidateSearchAreas(): Promise<void> {
const site = CoreSites.getRequiredCurrentSite();
await site.invalidateWsCacheForKey(CoreSearchGlobalSearchService.SEARCH_AREAS_CACHE_KEY);
}
/**
* Log event for viewing results.
*
@ -194,6 +234,16 @@ export class CoreSearchGlobalSearchService {
return result;
}
/**
* Check whether the given filter will necessarily yield an empty list of results.
*
* @param filters Filters.
* @returns Whether the given filters will return 0 results.
*/
protected filtersYieldEmptyResults(filters: CoreSearchGlobalSearchFilters): boolean {
return filters.courseIds?.length === 0 || filters.searchAreaCategoryIds?.length === 0;
}
/**
* Prepare search filters before sending to WS.
*

View File

@ -100,6 +100,27 @@ Feature: Test Global Search
# TODO test other results like course, user, and messages (global search generator not supported)
Scenario: Filter results
Given global search expects the query "page" and will return:
| type | idnumber |
| activity | page01 |
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
When I press "Filter" in the app
And I press "C1" in the app
And I press "Users" in the app
And global search expects the query "page" and will return:
| type | idnumber |
| activity | page02 |
And I press "Close" in the app
Then I should find "Test page 02" in the app
But I should not find "Test page 01" in the app
Scenario: See search banner
Given the following config values are set as admin:
| searchbannerenable | 1 |