From f3ae7e5e4a960de93419e8aafb4945565f06a48a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Thu, 12 Nov 2020 10:09:59 +0100 Subject: [PATCH] MOBILE-3565 searchbox: Searchbox component implementation --- src/app/app.module.ts | 2 + src/app/components/loading/loading.scss | 2 +- .../search/components/components.module.ts | 43 +++++ .../search-box/core-search-box.html | 28 +++ .../components/search-box/search-box.scss | 27 +++ .../components/search-box/search-box.ts | 173 ++++++++++++++++++ src/app/core/search/search.module.ts | 28 +++ .../core/search/services/search-history.ts | 133 ++++++++++++++ .../core/search/services/search.history.db.ts | 67 +++++++ src/app/core/settings/settings.module.ts | 1 - src/app/services/utils/dom.ts | 4 +- 11 files changed, 504 insertions(+), 4 deletions(-) create mode 100644 src/app/core/search/components/components.module.ts create mode 100644 src/app/core/search/components/search-box/core-search-box.html create mode 100644 src/app/core/search/components/search-box/search-box.scss create mode 100644 src/app/core/search/components/search-box/search-box.ts create mode 100644 src/app/core/search/search.module.ts create mode 100644 src/app/core/search/services/search-history.ts create mode 100644 src/app/core/search/services/search.history.db.ts diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 7ff90cadd..1465a4fa9 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -54,6 +54,7 @@ import { CoreUtilsProvider } from '@services/utils/utils'; import { initCoreFilepoolDB } from '@services/filepool.db'; import { initCoreSitesDB } from '@services/sites.db'; import { initCoreSyncDB } from '@services/sync.db'; +import { initCoreSearchHistoryDB } from '@core/search/services/search.history.db'; // Import core modules. import { CoreEmulatorModule } from '@core/emulator/emulator.module'; @@ -170,6 +171,7 @@ export class AppModule { initCoreFilepoolDB(); initCoreSitesDB(); initCoreSyncDB(); + initCoreSearchHistoryDB(); } } diff --git a/src/app/components/loading/loading.scss b/src/app/components/loading/loading.scss index b8289607b..2a054b7a3 100644 --- a/src/app/components/loading/loading.scss +++ b/src/app/components/loading/loading.scss @@ -38,6 +38,6 @@ } &.core-loading-loaded { - position: relative; + position: unset; } } diff --git a/src/app/core/search/components/components.module.ts b/src/app/core/search/components/components.module.ts new file mode 100644 index 000000000..132197f91 --- /dev/null +++ b/src/app/core/search/components/components.module.ts @@ -0,0 +1,43 @@ +// (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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IonicModule } from '@ionic/angular'; +import { FormsModule } from '@angular/forms'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CoreComponentsModule } from '@components/components.module'; + +import { CoreSearchBoxComponent } from './search-box/search-box'; + + +@NgModule({ + declarations: [ + CoreSearchBoxComponent, + ], + imports: [ + CommonModule, + IonicModule, + FormsModule, + TranslateModule.forChild(), + CoreDirectivesModule, + CoreComponentsModule, + ], + exports: [ + CoreSearchBoxComponent, + ], +}) +export class CoreSearchComponentsModule {} diff --git a/src/app/core/search/components/search-box/core-search-box.html b/src/app/core/search/components/search-box/core-search-box.html new file mode 100644 index 000000000..6989a6039 --- /dev/null +++ b/src/app/core/search/components/search-box/core-search-box.html @@ -0,0 +1,28 @@ + +
+ + + + + + + + + + + + + + + + {{item.searchedtext}} + + +
+
diff --git a/src/app/core/search/components/search-box/search-box.scss b/src/app/core/search/components/search-box/search-box.scss new file mode 100644 index 000000000..1884f0a13 --- /dev/null +++ b/src/app/core/search/components/search-box/search-box.scss @@ -0,0 +1,27 @@ +:host { + height: 80px; + display: block; + position: relative; + + ion-card { + position: absolute; + left: 0; + right: 0; + z-index: 4; + } + + ion-button.button:last-child { + margin-left: unset; + margin-inline-start: 10px; + } + + .core-search-history { + max-height: calc(-120px + 80vh); + overflow-y: auto; + + .item:hover { + --background: var(--gray-lighter); + cursor: pointer; + } + } +} diff --git a/src/app/core/search/components/search-box/search-box.ts b/src/app/core/search/components/search-box/search-box.ts new file mode 100644 index 000000000..615d5b270 --- /dev/null +++ b/src/app/core/search/components/search-box/search-box.ts @@ -0,0 +1,173 @@ +// (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, Input, Output, EventEmitter, OnInit } from '@angular/core'; + +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreSearchHistory, CoreSearchHistoryItem } from '../../services/search-history'; +import { Translate } from '@singletons/core.singletons'; + +/** + * Component to display a "search box". + * + * @description + * This component will display a standalone search box with its search button in order to have a better UX. + * + * Example usage: + * + */ +@Component({ + selector: 'core-search-box', + templateUrl: 'core-search-box.html', + styleUrls: ['search-box.scss'], +}) +export class CoreSearchBoxComponent implements OnInit { + + @Input() searchLabel?: string; // Label to be used on action button. + @Input() placeholder?: string; // Placeholder text for search text input. + @Input() autocorrect = 'on'; // Enables/disable Autocorrection on search text input. + @Input() spellcheck?: string | boolean = true; // Enables/disable Spellchecker on search text input. + @Input() autoFocus?: string | boolean; // Enables/disable Autofocus when entering view. + @Input() lengthCheck = 3; // Check value length before submit. If 0, any string will be submitted. + @Input() showClear = true; // Show/hide clear button. + @Input() disabled = false; // Disables the input text. + @Input() protected initialSearch = ''; // Initial search text. + + /* If provided. It will save and display a history of searches for this particular Id. + * To use different history lists, place different Id. + * I.e. AddonMessagesContacts or CoreUserParticipants-6 (using the course Id).*/ + @Input() protected searchArea = ''; + + @Output() onSubmit: EventEmitter; // Send data when submitting the search form. + @Output() onClear: EventEmitter; // Send event when clearing the search form. + + formElement?: HTMLFormElement; + + searched = ''; // Last search emitted. + searchText = ''; + history: CoreSearchHistoryItem[] = []; + historyShown = false; + + constructor() { + this.onSubmit = new EventEmitter(); + this.onClear = new EventEmitter(); + } + + ngOnInit(): void { + this.searchLabel = this.searchLabel || Translate.instance.instant('core.search'); + this.placeholder = this.placeholder || Translate.instance.instant('core.search'); + this.spellcheck = CoreUtils.instance.isTrueOrOne(this.spellcheck); + this.showClear = CoreUtils.instance.isTrueOrOne(this.showClear); + this.searchText = this.initialSearch; + + if (this.searchArea) { + this.loadHistory(); + } + } + + /** + * Form submitted. + * + * @param e Event. + */ + submitForm(e?: Event): void { + e && e.preventDefault(); + e && e.stopPropagation(); + + if (this.searchText.length < this.lengthCheck) { + // The view should handle this case, but we check it here too just in case. + return; + } + + if (this.searchArea) { + this.saveSearchToHistory(this.searchText); + } + + CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, false, CoreSites.instance.getCurrentSiteId()); + + this.historyShown = false; + this.searched = this.searchText; + this.onSubmit.emit(this.searchText); + } + + /** + * Saves the search term onto the history. + * + * @param text Text to save. + * @return Promise resolved when done. + */ + protected async saveSearchToHistory(text: string): Promise { + try { + await CoreSearchHistory.instance.insertOrUpdateSearchText(this.searchArea, text.toLowerCase()); + } finally { + this.loadHistory(); + } + } + + /** + * Loads search history. + * + * @return Promise resolved when done. + */ + protected async loadHistory(): Promise { + this.history = await CoreSearchHistory.instance.getSearchHistory(this.searchArea); + } + + /** + * Select an item and use it for search text. + * + * @param e Event. + * @param text Selected text. + */ + historyClicked(e: Event, text: string): void { + if (this.searched != text) { + this.searchText = text; + this.submitForm(e); + } + } + + /** + * Form submitted. + */ + clearForm(): void { + this.searched = ''; + this.searchText = ''; + this.onClear.emit(); + } + + /** + * @param event Focus event on input element. + */ + focus(event: CustomEvent): void { + this.historyShown = true; + + if (!this.formElement) { + this.formElement = event.detail.target.closest('form'); + + + this.formElement?.addEventListener('blur', () => { + // Wait the new element to be focused. + setTimeout(() => { + if (document.activeElement?.closest('form') != this.formElement) { + this.historyShown = false; + } + }); + }, true); + } + } + +} diff --git a/src/app/core/search/search.module.ts b/src/app/core/search/search.module.ts new file mode 100644 index 000000000..4557cc77a --- /dev/null +++ b/src/app/core/search/search.module.ts @@ -0,0 +1,28 @@ +// (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 { NgModule } from '@angular/core'; +import { CoreSearchComponentsModule } from './components/components.module'; + +@NgModule({ + declarations: [ + ], + imports: [ + CoreSearchComponentsModule, + ], + providers: [ + CoreSearchComponentsModule, + ], +}) +export class CoreSearchModule {} diff --git a/src/app/core/search/services/search-history.ts b/src/app/core/search/services/search-history.ts new file mode 100644 index 000000000..9cf4b552c --- /dev/null +++ b/src/app/core/search/services/search-history.ts @@ -0,0 +1,133 @@ +// (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 { Injectable } from '@angular/core'; + +import { CoreSites } from '@services/sites'; +import { SQLiteDB } from '@classes/sqlitedb'; +import { CoreSearchHistoryDBRecord, SEARCH_HISTORY_TABLE_NAME } from './search.history.db'; +import { makeSingleton } from '@/app/singletons/core.singletons'; + +/** + * Service that enables adding a history to a search box. + */ +@Injectable({ + providedIn: 'root', +}) +export class CoreSearchHistoryProvider { + + protected static readonly HISTORY_LIMIT = 10; + + /** + * Get a search area history sorted by use. + * + * @param searchArea Search Area Name. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the list of items when done. + */ + async getSearchHistory(searchArea: string, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const conditions = { + searcharea: searchArea, + }; + + const history: CoreSearchHistoryDBRecord[] = await site.getDb().getRecords(SEARCH_HISTORY_TABLE_NAME, conditions); + + // Sorting by last used DESC. + return history.sort((a, b) => (b.lastused || 0) - (a.lastused || 0)); + } + + /** + * Controls search limit and removes the last item if overflows. + * + * @param searchArea Search area to control + * @param db SQLite DB where to perform the search. + * @return Resolved when done. + */ + protected async controlSearchLimit(searchArea: string, db: SQLiteDB): Promise { + const items = await this.getSearchHistory(searchArea); + if (items.length > CoreSearchHistoryProvider.HISTORY_LIMIT) { + // Over the limit. Remove the last. + const lastItem = items.pop(); + + const searchItem = { + searcharea: lastItem!.searcharea, + searchedtext: lastItem!.searchedtext, + }; + + await db.deleteRecords(SEARCH_HISTORY_TABLE_NAME, searchItem); + } + } + + /** + * Updates the search history item if exists. + * + * @param searchArea Area where the search has been performed. + * @param text Text of the performed text. + * @param db SQLite DB where to perform the search. + * @return True if exists, false otherwise. + */ + protected async updateExistingItem(searchArea: string, text: string, db: SQLiteDB): Promise { + const searchItem = { + searcharea: searchArea, + searchedtext: text, + }; + + try { + const existingItem: CoreSearchHistoryDBRecord = await db.getRecord(SEARCH_HISTORY_TABLE_NAME, searchItem); + + // If item exist, update time and number of times searched. + existingItem.lastused = Date.now(); + existingItem.times++; + + await db.updateRecords(SEARCH_HISTORY_TABLE_NAME, existingItem, searchItem); + + return true; + } catch { + return false; + } + } + + /** + * Inserts a searched term on the history. + * + * @param searchArea Area where the search has been performed. + * @param text Text of the performed text. + * @param siteId Site ID. If not defined, current site. + * @return Resolved when done. + */ + async insertOrUpdateSearchText(searchArea: string, text: string, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const db = site.getDb(); + + const exists = await this.updateExistingItem(searchArea, text, db); + + if (!exists) { + // If item is new, control the history does not goes over the limit. + const searchItem: CoreSearchHistoryDBRecord = { + searcharea: searchArea, + searchedtext: text, + lastused: Date.now(), + times: 1, + }; + + await db.insertRecord(SEARCH_HISTORY_TABLE_NAME, searchItem); + + await this.controlSearchLimit(searchArea, db); + } + } + +} + +export class CoreSearchHistory extends makeSingleton(CoreSearchHistoryProvider) {} diff --git a/src/app/core/search/services/search.history.db.ts b/src/app/core/search/services/search.history.db.ts new file mode 100644 index 000000000..6b1080d06 --- /dev/null +++ b/src/app/core/search/services/search.history.db.ts @@ -0,0 +1,67 @@ +// (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 { CoreSiteSchema, registerSiteSchema } from '@services/sites'; + +/** + * Database variables for CoreSearchHistory service. + */ +export const SEARCH_HISTORY_TABLE_NAME = 'seach_history'; +const SITE_SCHEMA: CoreSiteSchema = { + name: 'CoreSearchHistoryProvider', + version: 1, + tables: [ + { + name: SEARCH_HISTORY_TABLE_NAME, + columns: [ + { + name: 'searcharea', + type: 'TEXT', + notNull: true, + }, + { + name: 'lastused', + type: 'INTEGER', + notNull: true, + }, + { + name: 'times', + type: 'INTEGER', + notNull: true, + }, + { + name: 'searchedtext', + type: 'TEXT', + notNull: true, + }, + ], + primaryKeys: ['searcharea', 'searchedtext'], + }, + ], +}; + +/** + * Search history item definition. + */ +export type CoreSearchHistoryDBRecord = { + searcharea: string; // Search area where the search has been performed. + lastused: number; // Timestamp of the last search. + searchedtext: string; // Text of the performed search. + times: number; // Times search has been performed (if previously in history). +}; + +export const initCoreSearchHistoryDB = (): void => { + registerSiteSchema(SITE_SCHEMA); +}; + diff --git a/src/app/core/settings/settings.module.ts b/src/app/core/settings/settings.module.ts index 961772eb7..b9ed32dce 100644 --- a/src/app/core/settings/settings.module.ts +++ b/src/app/core/settings/settings.module.ts @@ -20,6 +20,5 @@ import { CoreSettingsRoutingModule } from './settings-routing.module'; imports: [ CoreSettingsRoutingModule, ], - declarations: [], }) export class CoreSettingsModule {} diff --git a/src/app/services/utils/dom.ts b/src/app/services/utils/dom.ts index d709d2925..a7fb099bd 100644 --- a/src/app/services/utils/dom.ts +++ b/src/app/services/utils/dom.ts @@ -1725,14 +1725,14 @@ export class CoreDomUtilsProvider { * @param online Whether the action was done in offline or not. * @param siteId The site affected. If not provided, no site affected. */ - triggerFormSubmittedEvent(formRef: ElementRef | undefined, online?: boolean, siteId?: string): void { + triggerFormSubmittedEvent(formRef: ElementRef | HTMLFormElement | undefined, online?: boolean, siteId?: string): void { if (!formRef) { return; } CoreEvents.trigger(CoreEvents.FORM_ACTION, { action: 'submit', - form: formRef.nativeElement, + form: formRef.nativeElement || formRef, online: !!online, }, siteId); }