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