MOBILE-3371 search: Filter global search
parent
65a4cc98f7
commit
6cf958eb8a
|
@ -7,7 +7,7 @@
|
||||||
"",
|
"",
|
||||||
"@Component({",
|
"@Component({",
|
||||||
" selector: '$2${TM_FILENAME_BASE}',",
|
" selector: '$2${TM_FILENAME_BASE}',",
|
||||||
" templateUrl: '$2${TM_FILENAME_BASE}.html',",
|
" templateUrl: '${TM_FILENAME_BASE}.html',",
|
||||||
"})",
|
"})",
|
||||||
"export class ${1:${TM_FILENAME_BASE}}Component {",
|
"export class ${1:${TM_FILENAME_BASE}}Component {",
|
||||||
"",
|
"",
|
||||||
|
@ -110,6 +110,24 @@
|
||||||
],
|
],
|
||||||
"description": "[Moodle] Create a Pure Singleton"
|
"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": {
|
"Innherit doc": {
|
||||||
"prefix": "inheritdoc",
|
"prefix": "inheritdoc",
|
||||||
"body": [
|
"body": [
|
||||||
|
|
|
@ -2310,7 +2310,12 @@
|
||||||
"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.allcourses": "search",
|
||||||
|
"core.search.allcategories": "local_moodlemobileapp",
|
||||||
"core.search.empty": "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.globalsearch": "search",
|
||||||
"core.search.noresults": "local_moodlemobileapp",
|
"core.search.noresults": "local_moodlemobileapp",
|
||||||
"core.search.noresultshelp": "local_moodlemobileapp",
|
"core.search.noresultshelp": "local_moodlemobileapp",
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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>
|
|
@ -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 {}
|
|
@ -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);
|
||||||
|
}
|
|
@ -1,5 +1,10 @@
|
||||||
{
|
{
|
||||||
|
"allcourses": "All courses",
|
||||||
|
"allcategories": "All categories",
|
||||||
"empty": "What are you searching for?",
|
"empty": "What are you searching for?",
|
||||||
|
"filtercategories": "Filter results by",
|
||||||
|
"filtercourses": "Search in",
|
||||||
|
"filterheader": "Filter",
|
||||||
"globalsearch": "Global search",
|
"globalsearch": "Global search",
|
||||||
"noresults": "No results for \"{{$a}}\"",
|
"noresults": "No results for \"{{$a}}\"",
|
||||||
"noresultshelp": "Check for typos or try using different keywords",
|
"noresultshelp": "Check for typos or try using different keywords",
|
||||||
|
|
|
@ -42,5 +42,11 @@
|
||||||
<p><small>{{ 'core.search.noresultshelp' | translate }}</small></p>
|
<p><small>{{ 'core.search.noresultshelp' | translate }}</small></p>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</core-empty-box>
|
</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>
|
</div>
|
||||||
</ion-content>
|
</ion-content>
|
||||||
|
|
|
@ -12,26 +12,33 @@
|
||||||
// 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 { Component, OnInit } from '@angular/core';
|
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||||
import { CoreDomUtils } from '@services/utils/dom';
|
import { CoreDomUtils } from '@services/utils/dom';
|
||||||
import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper';
|
import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper';
|
||||||
import { CoreSearchGlobalSearchResultsSource } from '@features/search/classes/global-search-results-source';
|
import { CoreSearchGlobalSearchResultsSource } from '@features/search/classes/global-search-results-source';
|
||||||
import { CoreSites } from '@services/sites';
|
import { CoreSites } from '@services/sites';
|
||||||
import { CoreUtils } from '@services/utils/utils';
|
import { CoreUtils } from '@services/utils/utils';
|
||||||
import { CoreSearchGlobalSearchResult, CoreSearchGlobalSearch } from '@features/search/services/global-search';
|
|
||||||
import { CoreAnalytics, CoreAnalyticsEventType } from '@services/analytics';
|
import { CoreAnalytics, CoreAnalyticsEventType } from '@services/analytics';
|
||||||
import { Translate } from '@singletons';
|
import { Translate } from '@singletons';
|
||||||
import { CoreUrlUtils } from '@services/utils/url';
|
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({
|
@Component({
|
||||||
selector: 'page-core-search-global-search',
|
selector: 'page-core-search-global-search',
|
||||||
templateUrl: 'global-search.html',
|
templateUrl: 'global-search.html',
|
||||||
})
|
})
|
||||||
export class CoreSearchGlobalSearchPage implements OnInit {
|
export class CoreSearchGlobalSearchPage implements OnInit, OnDestroy {
|
||||||
|
|
||||||
loadMoreError: string | null = null;
|
loadMoreError: string | null = null;
|
||||||
searchBanner: string | null = null;
|
searchBanner: string | null = null;
|
||||||
resultsSource = new CoreSearchGlobalSearchResultsSource('', {});
|
resultsSource = new CoreSearchGlobalSearchResultsSource('', {});
|
||||||
|
private filtersObserver?: CoreEventObserver;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @inheritdoc
|
* @inheritdoc
|
||||||
|
@ -43,6 +50,18 @@ export class CoreSearchGlobalSearchPage implements OnInit {
|
||||||
if (CoreUtils.isTrueOrOne(site.config?.searchbannerenable) && searchBanner.length > 0) {
|
if (CoreUtils.isTrueOrOne(site.config?.searchbannerenable) && searchBanner.length > 0) {
|
||||||
this.searchBanner = searchBanner;
|
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();
|
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.
|
* Visit a result's origin.
|
||||||
*
|
*
|
||||||
|
|
|
@ -19,8 +19,23 @@ import { CoreWSExternalWarning } from '@services/ws';
|
||||||
import { CoreCourseListItem, CoreCourses } from '@features/courses/services/courses';
|
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';
|
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_PAGE_LENGTH = 10;
|
||||||
|
export const CORE_SEARCH_GLOBAL_SEARCH_FILTERS_UPDATED = 'core-search-global-search-filters-updated';
|
||||||
|
|
||||||
export type CoreSearchGlobalSearchResult = {
|
export type CoreSearchGlobalSearchResult = {
|
||||||
id: number;
|
id: number;
|
||||||
|
@ -66,6 +81,8 @@ export interface CoreSearchGlobalSearchFilters {
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class CoreSearchGlobalSearchService {
|
export class CoreSearchGlobalSearchService {
|
||||||
|
|
||||||
|
private static readonly SEARCH_AREAS_CACHE_KEY = 'CoreSearchGlobalSearch:SearchAreas';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get results.
|
* Get results.
|
||||||
*
|
*
|
||||||
|
@ -79,6 +96,13 @@ export class CoreSearchGlobalSearchService {
|
||||||
filters: CoreSearchGlobalSearchFilters,
|
filters: CoreSearchGlobalSearchFilters,
|
||||||
page: number,
|
page: number,
|
||||||
): Promise<{ results: CoreSearchGlobalSearchResult[]; canLoadMore: boolean }> {
|
): Promise<{ results: CoreSearchGlobalSearchResult[]; canLoadMore: boolean }> {
|
||||||
|
if (this.filtersYieldEmptyResults(filters)) {
|
||||||
|
return {
|
||||||
|
results: [],
|
||||||
|
canLoadMore: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const site = CoreSites.getRequiredCurrentSite();
|
const site = CoreSites.getRequiredCurrentSite();
|
||||||
const params: CoreSearchGetResultsWSParams = {
|
const params: CoreSearchGetResultsWSParams = {
|
||||||
query,
|
query,
|
||||||
|
@ -103,6 +127,10 @@ export class CoreSearchGlobalSearchService {
|
||||||
* @returns Top search results.
|
* @returns Top search results.
|
||||||
*/
|
*/
|
||||||
async getTopResults(query: string, filters: CoreSearchGlobalSearchFilters): Promise<CoreSearchGlobalSearchResult[]> {
|
async getTopResults(query: string, filters: CoreSearchGlobalSearchFilters): Promise<CoreSearchGlobalSearchResult[]> {
|
||||||
|
if (this.filtersYieldEmptyResults(filters)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const site = CoreSites.getRequiredCurrentSite();
|
const site = CoreSites.getRequiredCurrentSite();
|
||||||
const params: CoreSearchGetTopResultsWSParams = {
|
const params: CoreSearchGetTopResultsWSParams = {
|
||||||
query,
|
query,
|
||||||
|
@ -124,7 +152,10 @@ export class CoreSearchGlobalSearchService {
|
||||||
const site = CoreSites.getRequiredCurrentSite();
|
const site = CoreSites.getRequiredCurrentSite();
|
||||||
const params: CoreSearchGetSearchAreasListWSParams = {};
|
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 => ({
|
return areas.map(area => ({
|
||||||
id: area.id,
|
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.
|
* Log event for viewing results.
|
||||||
*
|
*
|
||||||
|
@ -194,6 +234,16 @@ export class CoreSearchGlobalSearchService {
|
||||||
return result;
|
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.
|
* Prepare search filters before sending to WS.
|
||||||
*
|
*
|
||||||
|
|
|
@ -100,6 +100,27 @@ Feature: Test Global Search
|
||||||
|
|
||||||
# TODO test other results like course, user, and messages (global search generator not supported)
|
# 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
|
Scenario: See search banner
|
||||||
Given the following config values are set as admin:
|
Given the following config values are set as admin:
|
||||||
| searchbannerenable | 1 |
|
| searchbannerenable | 1 |
|
||||||
|
|
Loading…
Reference in New Issue