From 6cf958eb8a619d58a97ff17770a1fa8ef52e7f0e Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Wed, 28 Jun 2023 17:23:30 +0200 Subject: [PATCH] MOBILE-3371 search: Filter global search --- .vscode/moodle.code-snippets | 20 +- scripts/langindex.json | 5 + .../global-search-filters.component.ts | 267 ++++++++++++++++++ .../global-search-filters.html | 58 ++++ .../global-search-filters.module.ts | 30 ++ .../global-search-filters.scss | 21 ++ src/core/features/search/lang.json | 5 + .../pages/global-search/global-search.html | 6 + .../pages/global-search/global-search.ts | 42 ++- .../features/search/services/global-search.ts | 52 +++- .../search/tests/behat/global-search.feature | 21 ++ 11 files changed, 522 insertions(+), 5 deletions(-) create mode 100644 src/core/features/search/components/global-search-filters/global-search-filters.component.ts create mode 100644 src/core/features/search/components/global-search-filters/global-search-filters.html create mode 100644 src/core/features/search/components/global-search-filters/global-search-filters.module.ts create mode 100644 src/core/features/search/components/global-search-filters/global-search-filters.scss diff --git a/.vscode/moodle.code-snippets b/.vscode/moodle.code-snippets index d68e02b60..8d5b7c30d 100644 --- a/.vscode/moodle.code-snippets +++ b/.vscode/moodle.code-snippets @@ -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": [ diff --git a/scripts/langindex.json b/scripts/langindex.json index dc6b44b60..3bba525dc 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -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", diff --git a/src/core/features/search/components/global-search-filters/global-search-filters.component.ts b/src/core/features/search/components/global-search-filters/global-search-filters.component.ts new file mode 100644 index 000000000..54be5eea6 --- /dev/null +++ b/src/core/features/search/components/global-search-filters/global-search-filters.component.ts @@ -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 & { 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[] = []; + allCourses: boolean | null = true; + courses: Filter[] = []; + + @Input() filters?: CoreSearchGlobalSearchFilters; + + private newFilters: CoreSearchGlobalSearchFilters = {}; + + /** + * @inheritdoc + */ + async ngOnInit(): Promise { + 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): 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): 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 { + 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 { + 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 { + 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); + } + +} diff --git a/src/core/features/search/components/global-search-filters/global-search-filters.html b/src/core/features/search/components/global-search-filters/global-search-filters.html new file mode 100644 index 000000000..5f38275b7 --- /dev/null +++ b/src/core/features/search/components/global-search-filters/global-search-filters.html @@ -0,0 +1,58 @@ + + + +

{{ 'core.search.filterheader' | translate }}

+
+ + + + + +
+
+ + + + + + + + + + {{ 'core.search.filtercategories' | translate }} + + + + {{ 'core.search.allcategories' | translate }} + + + + + + + + + + + + + + {{ 'core.search.filtercourses' | translate }} + + + + {{ 'core.search.allcourses' | translate }} + + + + + + + + + + + + diff --git a/src/core/features/search/components/global-search-filters/global-search-filters.module.ts b/src/core/features/search/components/global-search-filters/global-search-filters.module.ts new file mode 100644 index 000000000..0cd203468 --- /dev/null +++ b/src/core/features/search/components/global-search-filters/global-search-filters.module.ts @@ -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 {} diff --git a/src/core/features/search/components/global-search-filters/global-search-filters.scss b/src/core/features/search/components/global-search-filters/global-search-filters.scss new file mode 100644 index 000000000..d9a9c2d1d --- /dev/null +++ b/src/core/features/search/components/global-search-filters/global-search-filters.scss @@ -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); +} diff --git a/src/core/features/search/lang.json b/src/core/features/search/lang.json index 0350a4c0f..24824a183 100644 --- a/src/core/features/search/lang.json +++ b/src/core/features/search/lang.json @@ -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", diff --git a/src/core/features/search/pages/global-search/global-search.html b/src/core/features/search/pages/global-search/global-search.html index eaaebcd3f..afbaf81af 100644 --- a/src/core/features/search/pages/global-search/global-search.html +++ b/src/core/features/search/pages/global-search/global-search.html @@ -42,5 +42,11 @@

{{ 'core.search.noresultshelp' | translate }}

+ + + + + + diff --git a/src/core/features/search/pages/global-search/global-search.ts b/src/core/features/search/pages/global-search/global-search.ts index d1cce9b88..0adbafc40 100644 --- a/src/core/features/search/pages/global-search/global-search.ts +++ b/src/core/features/search/pages/global-search/global-search.ts @@ -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 { + const { CoreSearchGlobalSearchFiltersComponent } = + await import('@features/search/components/global-search-filters/global-search-filters.module'); + + await CoreDomUtils.openSideModal({ + 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. * diff --git a/src/core/features/search/services/global-search.ts b/src/core/features/search/services/global-search.ts index 9fbcdc43a..a661050aa 100644 --- a/src/core/features/search/services/global-search.ts +++ b/src/core/features/search/services/global-search.ts @@ -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 { + 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('core_search_get_search_areas_list', params); + const { areas } = await site.read('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 { + 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. * diff --git a/src/core/features/search/tests/behat/global-search.feature b/src/core/features/search/tests/behat/global-search.feature index 8bf5728c2..d0bc85cef 100644 --- a/src/core/features/search/tests/behat/global-search.feature +++ b/src/core/features/search/tests/behat/global-search.feature @@ -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 |