Skip to content

Commit

Permalink
MOBILE-3371 search: Filter global search
Browse files Browse the repository at this point in the history
  • Loading branch information
NoelDeMartin committed Jun 28, 2023
1 parent 5e08921 commit b5804f7
Show file tree
Hide file tree
Showing 11 changed files with 472 additions and 4 deletions.
20 changes: 19 additions & 1 deletion .vscode/moodle.code-snippets
Original file line number Diff line number Diff line change
Expand Up @@ -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 {",
"",
Expand Down Expand Up @@ -110,4 +110,22 @@
],
"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": ""
}
}
5 changes: 5 additions & 0 deletions scripts/langindex.json
Original file line number Diff line number Diff line change
Expand Up @@ -2299,7 +2299,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",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
// (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';

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 ?? {};

// Fetch search area categories.
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);

// Fetch courses.
const courses = await CoreCourses.getUserCourses(true);

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);
}

/**
* 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();
}

/**
* 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
*/
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);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<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-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>
Original file line number Diff line number Diff line change
@@ -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 {}
Original file line number Diff line number Diff line change
@@ -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);
}
Loading

0 comments on commit b5804f7

Please sign in to comment.