From c2da659be34087b2a6bda1e96d1da9b8ba879db2 Mon Sep 17 00:00:00 2001 From: Albert Gasset Date: Fri, 25 May 2018 15:52:01 +0200 Subject: [PATCH] MOBILE-2342 glossary: Implement index and mode-picker components --- .../glossary/components/components.module.ts | 51 +++ .../mod/glossary/components/index/index.html | 64 +++ .../mod/glossary/components/index/index.ts | 400 ++++++++++++++++++ .../components/mode-picker/mode-picker.html | 6 + .../components/mode-picker/mode-picker.ts | 69 +++ src/addon/mod/glossary/glossary.module.ts | 6 + 6 files changed, 596 insertions(+) create mode 100644 src/addon/mod/glossary/components/components.module.ts create mode 100644 src/addon/mod/glossary/components/index/index.html create mode 100644 src/addon/mod/glossary/components/index/index.ts create mode 100644 src/addon/mod/glossary/components/mode-picker/mode-picker.html create mode 100644 src/addon/mod/glossary/components/mode-picker/mode-picker.ts diff --git a/src/addon/mod/glossary/components/components.module.ts b/src/addon/mod/glossary/components/components.module.ts new file mode 100644 index 000000000..c37c35d36 --- /dev/null +++ b/src/addon/mod/glossary/components/components.module.ts @@ -0,0 +1,51 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CorePipesModule } from '@pipes/pipes.module'; +import { CoreCourseComponentsModule } from '@core/course/components/components.module'; +import { AddonModGlossaryIndexComponent } from './index/index'; +import { AddonModGlossaryModePickerPopoverComponent } from './mode-picker/mode-picker'; + +@NgModule({ + declarations: [ + AddonModGlossaryIndexComponent, + AddonModGlossaryModePickerPopoverComponent + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + CorePipesModule, + CoreCourseComponentsModule + ], + providers: [ + ], + exports: [ + AddonModGlossaryIndexComponent, + AddonModGlossaryModePickerPopoverComponent + ], + entryComponents: [ + AddonModGlossaryIndexComponent, + AddonModGlossaryModePickerPopoverComponent + ] +}) +export class AddonModGlossaryComponentsModule {} diff --git a/src/addon/mod/glossary/components/index/index.html b/src/addon/mod/glossary/components/index/index.html new file mode 100644 index 000000000..3778a757a --- /dev/null +++ b/src/addon/mod/glossary/components/index/index.html @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + {{ 'core.hasdatatosync' | translate:{$a: moduleName} }} + + + + + + + + + {{ 'addon.mod_glossary.entriestobesynced' | translate }} + + +

{{entry.concept}}

+
+
+ + + + + + {{getDivider(entry)}} + + + +

{{entry.concept}}

+
+
+
+ + + + + + +
+
+
diff --git a/src/addon/mod/glossary/components/index/index.ts b/src/addon/mod/glossary/components/index/index.ts new file mode 100644 index 000000000..cb19029fa --- /dev/null +++ b/src/addon/mod/glossary/components/index/index.ts @@ -0,0 +1,400 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, Injector, ViewChild } from '@angular/core'; +import { Content, PopoverController } from 'ionic-angular'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; +import { CoreCourseModuleMainActivityComponent } from '@core/course/classes/main-activity-component'; +import { AddonModGlossaryProvider } from '../../providers/glossary'; +import { AddonModGlossaryOfflineProvider } from '../../providers/offline'; +import { AddonModGlossarySyncProvider } from '../../providers/sync'; +import { AddonModGlossaryModePickerPopoverComponent } from '../mode-picker/mode-picker'; + +type FetchMode = 'author_all' | 'cat_all' | 'newest_first' | 'recently_updated' | 'search' | 'letter_all'; + +/** + * Component that displays a glossary entry page. + */ +@Component({ + selector: 'addon-mod-glossary-index', + templateUrl: 'index.html', +}) +export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivityComponent { + @ViewChild(CoreSplitViewComponent) splitviewCtrl: CoreSplitViewComponent; + @ViewChild(Content) content: Content; + + component = AddonModGlossaryProvider.COMPONENT; + moduleName = 'glossary'; + + fetchMode: FetchMode; + viewMode: string; + isSearch = false; + entries = []; + offlineEntries = []; + canAdd = false; + canLoadMore = false; + loadingMessage = this.translate.instant('core.loading'); + selectedEntry: number; + + protected syncEventName = AddonModGlossarySyncProvider.AUTO_SYNCED; + protected glossary: any; + protected fetchFunction: Function; + protected fetchInvalidate: Function; + protected fetchArguments: any[]; + protected showDivider: (entry: any, previous?: any) => boolean; + protected getDivider: (entry: any) => string; + protected addEntryObserver: any; + + constructor(injector: Injector, + private popoverCtrl: PopoverController, + private glossaryProvider: AddonModGlossaryProvider, + private glossaryOffline: AddonModGlossaryOfflineProvider, + private glossarySync: AddonModGlossarySyncProvider) { + super(injector); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + super.ngOnInit(); + + // When an entry is added, we reload the data. + this.addEntryObserver = this.eventsProvider.on(AddonModGlossaryProvider.ADD_ENTRY_EVENT, this.eventReceived.bind(this)); + + this.loadContent(false, true).then(() => { + if (!this.glossary) { + return; + } + + if (this.splitviewCtrl.isOn()) { + // Load the first entry. + if (this.entries.length > 0) { + this.openEntry(this.entries[0].id); + } + } + + this.glossaryProvider.logView(this.glossary.id, this.viewMode).then(() => { + this.courseProvider.checkModuleCompletion(this.courseId, this.module.completionstatus); + }).catch((error) => { + // Ignore errors. + }); + }); + } + + /** + * Download the component contents. + * + * @param {boolean} [refresh=false] Whether we're refreshing data. + * @param {boolean} [sync=false] If the refresh needs syncing. + * @param {boolean} [showErrors=false] Wether to show errors to the user or hide them. + * @return {Promise} Promise resolved when done. + */ + protected fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise { + return this.glossaryProvider.getGlossary(this.courseId, this.module.id).then((glossary) => { + this.glossary = glossary; + + this.description = glossary.intro || this.description; + this.canAdd = (this.glossaryProvider.isPluginEnabledForEditing() && glossary.canaddentry) || false; + + if (!this.fetchMode) { + this.switchMode('letter_all'); + } + + if (sync) { + // Try to synchronize the glossary. + return this.syncActivity(showErrors); + } + }).then(() => { + + return this.fetchEntries().then(() => { + // Check if there are responses stored in offline. + return this.glossaryOffline.getGlossaryNewEntries(this.glossary.id).then((offlineEntries) => { + offlineEntries.sort((a, b) => a.concept.localeCompare(b.fullname)); + this.hasOffline = !!offlineEntries.length; + this.offlineEntries = offlineEntries || []; + }); + }); + }).then(() => { + // All data obtained, now fill the context menu. + this.fillContextMenu(refresh); + }); + } + + /** + * Convenience function to fetch entries. + * + * @param {boolean} [append=false] True if fetched entries are appended to exsiting ones. + * @return {Promise} Promise resolved when done. + */ + protected fetchEntries(append: boolean = false): Promise { + if (!this.fetchFunction || !this.fetchArguments) { + // This happens in search mode with an empty query. + return Promise.resolve({entries: [], count: 0}); + } + + const limitFrom = append ? this.entries.length : 0; + const limitNum = AddonModGlossaryProvider.LIMIT_ENTRIES; + + return this.glossaryProvider.fetchEntries(this.fetchFunction, this.fetchArguments, limitFrom, limitNum).then((result) => { + if (append) { + Array.prototype.push.apply(this.entries, result.entries); + } else { + this.entries = result.entries; + } + this.canLoadMore = this.entries.length < result.count; + }).catch((error) => { + this.canLoadMore = false; // Set to false to prevent infinite calls with infinite-loading. + + return Promise.reject(error); + }); + } + + /** + * Perform the invalidate content function. + * + * @return {Promise} Resolved when done. + */ + protected invalidateContent(): Promise { + const promises = []; + + if (this.fetchInvalidate && this.fetchArguments) { + promises.push(this.fetchInvalidate.apply(this.glossaryProvider, this.fetchArguments)); + } + + promises.push(this.glossaryProvider.invalidateCourseGlossaries(this.courseId)); + + if (this.glossary && this.glossary.id) { + promises.push(this.glossaryProvider.invalidateCategories(this.glossary.id)); + } + + return Promise.all(promises); + } + + /** + * Performs the sync of the activity. + * + * @return {Promise} Promise resolved when done. + */ + protected sync(): Promise { + return this.glossarySync.syncGlossaryEntries(this.glossary.id); + } + + /** + * Checks if sync has succeed from result sync data. + * + * @param {any} result Data returned on the sync function. + * @return {boolean} Whether it succeed or not. + */ + protected hasSyncSucceed(result: any): boolean { + return result.updated; + } + + /** + * Compares sync event data with current data to check if refresh content is needed. + * + * @param {any} syncEventData Data receiven on sync observer. + * @return {boolean} True if refresh is needed, false otherwise. + */ + protected isRefreshSyncNeeded(syncEventData: any): boolean { + return this.glossary && syncEventData.glossaryId == this.glossary.id && + syncEventData.userId == this.sitesProvider.getCurrentSiteUserId(); + } + + /** + * Change fetch mode. + * + * @param {FetchMode} mode New mode. + */ + protected switchMode(mode: FetchMode): void { + this.fetchMode = mode; + + switch (mode) { + case 'author_all': + // Browse by author. + this.viewMode = 'author'; + this.fetchFunction = this.glossaryProvider.getEntriesByAuthor; + this.fetchInvalidate = this.glossaryProvider.invalidateEntriesByAuthor; + this.fetchArguments = [this.glossary.id, 'ALL', 'LASTNAME', 'ASC']; + this.getDivider = (entry: any): string => entry.userfullname; + this.showDivider = (entry: any, previous?: any): boolean => { + return previous === 'undefined' || entry.userid != previous.userid; + }; + break; + case 'cat_all': + // Browse by category. + this.viewMode = 'cat'; + this.fetchFunction = this.glossaryProvider.getEntriesByCategory; + this.fetchInvalidate = this.glossaryProvider.invalidateEntriesByCategory; + this.fetchArguments = [this.glossary.id, AddonModGlossaryProvider.SHOW_ALL_CATERGORIES]; + this.getDivider = (entry: any): string => entry.categoryname; + this.showDivider = (entry?: any, previous?: any): boolean => { + return !previous || this.getDivider(entry) != this.getDivider(previous); + }; + break; + case 'newest_first': + // Newest first. + this.viewMode = 'date'; + this.fetchFunction = this.glossaryProvider.getEntriesByDate; + this.fetchInvalidate = this.glossaryProvider.invalidateEntriesByDate; + this.fetchArguments = [this.glossary.id, 'CREATION', 'DESC']; + this.getDivider = null; + this.showDivider = (): boolean => false; + break; + case 'recently_updated': + // Recently updated. + this.viewMode = 'date'; + this.fetchFunction = this.glossaryProvider.getEntriesByDate; + this.fetchInvalidate = this.glossaryProvider.invalidateEntriesByDate; + this.fetchArguments = [this.glossary.id, 'UPDATE', 'DESC']; + this.getDivider = null; + this.showDivider = (): boolean => false; + break; + case 'search': + // Search for entries. + this.viewMode = 'search'; + this.fetchFunction = this.glossaryProvider.getEntriesBySearch; + this.fetchInvalidate = this.glossaryProvider.invalidateEntriesBySearch; + this.fetchArguments = null; // Dynamically set later. + this.getDivider = null; + this.showDivider = (): boolean => false; + break; + case 'letter_all': + default: + // Consider it is 'letter_all'. + this.viewMode = 'letter'; + this.fetchMode = 'letter_all'; + this.fetchFunction = this.glossaryProvider.getEntriesByLetter; + this.fetchInvalidate = this.glossaryProvider.invalidateEntriesByLetter; + this.fetchArguments = [this.glossary.id, 'ALL']; + this.getDivider = (entry: any): string => entry.concept.substr(0, 1).toUpperCase(); + this.showDivider = (entry?: any, previous?: any): boolean => { + return !previous || this.getDivider(entry) != this.getDivider(previous); + }; + break; + } + } + + /** + * Convenience function to load more forum discussions. + * + * @return {Promise} Promise resolved when done. + */ + loadMoreEntries(): Promise { + return this.fetchEntries(true).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'addon.mod_glossary.errorloadingentries', true); + }); + } + + /** + * Show the mode picker menu. + * + * @param {MouseEvent} event Event. + */ + openModePicker(event: MouseEvent): void { + const popover = this.popoverCtrl.create(AddonModGlossaryModePickerPopoverComponent, { + glossary: this.glossary, + selectedMode: this.fetchMode + }); + + popover.onDidDismiss((newMode: FetchMode) => { + if (newMode === this.fetchMode) { + return; + } + + this.loadingMessage = this.translate.instant('core.loading'); + this.content.scrollToTop(); + this.switchMode(newMode); + + if (this.fetchMode === 'search') { + // If it's not an instant search, then we reset the values. + this.entries = []; + this.canLoadMore = false; + } else { + this.loaded = false; + this.loadContent(); + } + }); + + popover.present({ + ev: event + }); + } + + /** + * Opens an entry. + * + * @param {number} entryId Entry id. + */ + openEntry(entryId: number): void { + const params = { + courseId: this.courseId, + entryId: entryId, + }; + this.splitviewCtrl.push('AddonModGlossaryEntryPage', params); + this.selectedEntry = entryId; + } + + /** + * Opens new entry editor. + * + * @param {any} [entry] Offline entry to edit. + */ + openNewEntry(entry?: any): void { + const params = { + courseId: this.courseId, + module: this.module, + glossary: this.glossary, + entry: entry, + }; + this.splitviewCtrl.getMasterNav().push('AddonModGlossaryEditPage', params); + this.selectedEntry = 0; + } + + /** + * Search entries. + * + * @param {string} query Text entered on the search box. + */ + search(query: string): void { + this.loadingMessage = this.translate.instant('core.searching'); + this.fetchArguments = [this.glossary.id, query, 1, 'CONCEPT', 'ASC']; + this.loaded = false; + this.loadContent(); + } + + /** + * Function called when we receive an event of new entry. + * + * @param {any} data Event data. + */ + protected eventReceived(data: any): void { + if (this.glossary && this.glossary.id === data.glossaryId) { + this.loaded = false; + this.loadContent(); + + // Check completion since it could be configured to complete once the user adds a new discussion or replies. + this.courseProvider.checkModuleCompletion(this.courseId, this.module.completionstatus); + } + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + super.ngOnDestroy(); + + this.addEntryObserver && this.addEntryObserver.off(); + } +} diff --git a/src/addon/mod/glossary/components/mode-picker/mode-picker.html b/src/addon/mod/glossary/components/mode-picker/mode-picker.html new file mode 100644 index 000000000..df7d9e525 --- /dev/null +++ b/src/addon/mod/glossary/components/mode-picker/mode-picker.html @@ -0,0 +1,6 @@ + + + {{mode.langkey | translate}} + + + diff --git a/src/addon/mod/glossary/components/mode-picker/mode-picker.ts b/src/addon/mod/glossary/components/mode-picker/mode-picker.ts new file mode 100644 index 000000000..f85118cef --- /dev/null +++ b/src/addon/mod/glossary/components/mode-picker/mode-picker.ts @@ -0,0 +1,69 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 } from '@angular/core'; +import { NavParams, ViewController } from 'ionic-angular'; + +/** + * Component to display the mode picker. + */ +@Component({ + selector: 'addon-mod-glossary-mode-picker-popover', + templateUrl: 'mode-picker.html' +}) +export class AddonModGlossaryModePickerPopoverComponent { + modes = []; + selectedMode: string; + + constructor(navParams: NavParams, private viewCtrl: ViewController) { + this.selectedMode = navParams.get('selectedMode'); + const glossary = navParams.get('glossary'); + + // Preparing browse modes. + this.modes = [ + {key: 'search', langkey: 'addon.mod_glossary.bysearch'} + ]; + glossary.browsemodes.forEach((mode) => { + switch (mode) { + case 'letter' : + this.modes.push({key: 'letter_all', langkey: 'addon.mod_glossary.byalphabet'}); + break; + case 'cat' : + this.modes.push({key: 'cat_all', langkey: 'addon.mod_glossary.bycategory'}); + break; + case 'date' : + this.modes.push({key: 'newest_first', langkey: 'addon.mod_glossary.bynewestfirst'}); + this.modes.push({key: 'recently_updated', langkey: 'addon.mod_glossary.byrecentlyupdated'}); + break; + case 'author' : + this.modes.push({key: 'author_all', langkey: 'addon.mod_glossary.byauthor'}); + break; + default: + } + }); + } + + /** + * Function called when a mode is clicked. + * + * @param {Event} event Click event. + * @param {string} key Clicked mode key. + * @return {boolean} Return true if success, false if error. + */ + modePicked(event: Event, key: string): boolean { + this.viewCtrl.dismiss(key); + + return true; + } +} diff --git a/src/addon/mod/glossary/glossary.module.ts b/src/addon/mod/glossary/glossary.module.ts index 0c12b9b7e..6c984079d 100644 --- a/src/addon/mod/glossary/glossary.module.ts +++ b/src/addon/mod/glossary/glossary.module.ts @@ -15,15 +15,21 @@ import { NgModule } from '@angular/core'; import { AddonModGlossaryProvider } from './providers/glossary'; import { AddonModGlossaryOfflineProvider } from './providers/offline'; +import { AddonModGlossaryHelperProvider } from './providers/helper'; +import { AddonModGlossarySyncProvider } from './providers/sync'; +import { AddonModGlossaryComponentsModule } from './components/components.module'; @NgModule({ declarations: [ ], imports: [ + AddonModGlossaryComponentsModule, ], providers: [ AddonModGlossaryProvider, AddonModGlossaryOfflineProvider, + AddonModGlossaryHelperProvider, + AddonModGlossarySyncProvider, ] }) export class AddonModGlossaryModule {