// (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 { ContextLevel } from '@/core/constants'; import { AfterViewInit, Component, OnDestroy, OnInit, Optional, ViewChild } from '@angular/core'; import { ActivatedRoute, Params } from '@angular/router'; import { CorePageItemsListManager } from '@classes/page-items-list-manager'; import { CoreSplitViewComponent } from '@components/split-view/split-view'; import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component'; import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; import { CoreCourse } from '@features/course/services/course'; import { CoreRatingProvider } from '@features/rating/services/rating'; import { CoreRatingOffline } from '@features/rating/services/rating-offline'; import { CoreRatingSyncProvider } from '@features/rating/services/rating-sync'; import { IonContent } from '@ionic/angular'; import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreTextUtils } from '@services/utils/text'; import { PopoverController, Translate } from '@singletons'; import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { AddonModGlossary, AddonModGlossaryEntry, AddonModGlossaryEntryWithCategory, AddonModGlossaryGetEntriesOptions, AddonModGlossaryGetEntriesWSResponse, AddonModGlossaryGlossary, AddonModGlossaryProvider, } from '../../services/glossary'; import { AddonModGlossaryOffline, AddonModGlossaryOfflineEntry } from '../../services/glossary-offline'; import { AddonModGlossaryAutoSyncData, AddonModGlossarySyncProvider, AddonModGlossarySyncResult, } from '../../services/glossary-sync'; import { AddonModGlossaryModuleHandlerService } from '../../services/handlers/module'; import { AddonModGlossaryPrefetchHandler } from '../../services/handlers/prefetch'; import { AddonModGlossaryModePickerPopoverComponent } from '../mode-picker/mode-picker'; /** * Component that displays a glossary entry page. */ @Component({ selector: 'addon-mod-glossary-index', templateUrl: 'addon-mod-glossary-index.html', }) export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivityComponent implements OnInit, AfterViewInit, OnDestroy { @ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent; component = AddonModGlossaryProvider.COMPONENT; moduleName = 'glossary'; isSearch = false; canAdd = false; loadMoreError = false; loadingMessage?: string; entries: AddonModGlossaryEntriesManager; hasOfflineRatings = false; glossary?: AddonModGlossaryGlossary; protected syncEventName = AddonModGlossarySyncProvider.AUTO_SYNCED; protected fetchFunction?: (options?: AddonModGlossaryGetEntriesOptions) => AddonModGlossaryGetEntriesWSResponse; protected fetchInvalidate?: () => Promise; protected addEntryObserver?: CoreEventObserver; protected fetchMode?: AddonModGlossaryFetchMode; protected viewMode?: string; protected fetchedEntriesCanLoadMore = false; protected fetchedEntries: AddonModGlossaryEntry[] = []; protected ratingOfflineObserver?: CoreEventObserver; protected ratingSyncObserver?: CoreEventObserver; getDivider?: (entry: AddonModGlossaryEntry) => string; showDivider: (entry: AddonModGlossaryEntry, previous?: AddonModGlossaryEntry) => boolean = () => false; constructor( route: ActivatedRoute, protected content?: IonContent, @Optional() courseContentsPage?: CoreCourseContentsPage, ) { super('AddonModGlossaryIndexComponent', content, courseContentsPage); this.entries = new AddonModGlossaryEntriesManager( route.component, this, courseContentsPage ? `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` : '', ); } /** * @inheritdoc */ async ngOnInit(): Promise { super.ngOnInit(); this.loadingMessage = Translate.instant('core.loading'); // When an entry is added, we reload the data. this.addEntryObserver = CoreEvents.on(AddonModGlossaryProvider.ADD_ENTRY_EVENT, (data) => { if (this.glossary && this.glossary.id === data.glossaryId) { this.showLoadingAndRefresh(false); // Check completion since it could be configured to complete once the user adds a new entry. CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); } }); // Listen for offline ratings saved and synced. this.ratingOfflineObserver = CoreEvents.on(CoreRatingProvider.RATING_SAVED_EVENT, (data) => { if (this.glossary && data.component == 'mod_glossary' && data.ratingArea == 'entry' && data.contextLevel == 'module' && data.instanceId == this.glossary.coursemodule) { this.hasOfflineRatings = true; } }); this.ratingSyncObserver = CoreEvents.on(CoreRatingSyncProvider.SYNCED_EVENT, (data) => { if (this.glossary && data.component == 'mod_glossary' && data.ratingArea == 'entry' && data.contextLevel == 'module' && data.instanceId == this.glossary.coursemodule) { this.hasOfflineRatings = false; } }); } /** * @inheritdoc */ async ngAfterViewInit(): Promise { await this.loadContent(false, true); if (!this.glossary) { return; } this.entries.start(this.splitView); try { await AddonModGlossary.logView(this.glossary.id, this.viewMode!, this.glossary.name); CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); } catch (error) { // Ignore errors. } } /** * @inheritdoc */ protected async fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise { try { this.glossary = await AddonModGlossary.getGlossary(this.courseId, this.module.id); this.description = this.glossary.intro || this.description; this.canAdd = (AddonModGlossary.isPluginEnabledForEditing() && !!this.glossary.canaddentry) || false; this.dataRetrieved.emit(this.glossary); if (!this.fetchMode) { this.switchMode('letter_all'); } if (sync) { // Try to synchronize the glossary. await this.syncActivity(showErrors); } const [hasOfflineRatings] = await Promise.all([ CoreRatingOffline.hasRatings('mod_glossary', 'entry', ContextLevel.MODULE, this.glossary.coursemodule), this.fetchEntries(), ]); this.hasOfflineRatings = hasOfflineRatings; } finally { this.fillContextMenu(refresh); } } /** * Convenience function to fetch entries. * * @param append True if fetched entries are appended to exsiting ones. * @return Promise resolved when done. */ protected async fetchEntries(append: boolean = false): Promise { if (!this.fetchFunction) { return; } this.loadMoreError = false; const from = append ? this.entries.onlineEntries.length : 0; const result = await this.fetchFunction({ from: from, cmId: this.module.id, }); const hasMoreEntries = from + result.entries.length < result.count; if (append) { this.entries.setItems(this.entries.items.concat(result.entries), hasMoreEntries); } else { this.entries.setOnlineEntries(result.entries, hasMoreEntries); } // Now get the ofline entries. // Check if there are responses stored in offline. const offlineEntries = await AddonModGlossaryOffline.getGlossaryNewEntries(this.glossary!.id); offlineEntries.sort((a, b) => a.concept.localeCompare(b.concept)); this.hasOffline = !!offlineEntries.length; this.entries.setOfflineEntries(offlineEntries); } /** * @inheritdoc */ protected async invalidateContent(): Promise { const promises: Promise[] = []; if (this.fetchInvalidate) { promises.push(this.fetchInvalidate()); } promises.push(AddonModGlossary.invalidateCourseGlossaries(this.courseId)); if (this.glossary) { promises.push(AddonModGlossary.invalidateCategories(this.glossary.id)); } await Promise.all(promises); } /** * Performs the sync of the activity. * * @return Promise resolved when done. */ protected sync(): Promise { return AddonModGlossaryPrefetchHandler.sync(this.module, this.courseId); } /** * Checks if sync has succeed from result sync data. * * @param result Data returned on the sync function. * @return Whether it succeed or not. */ protected hasSyncSucceed(result: AddonModGlossarySyncResult): boolean { return result.updated; } /** * Compares sync event data with current data to check if refresh content is needed. * * @param syncEventData Data receiven on sync observer. * @return True if refresh is needed, false otherwise. */ protected isRefreshSyncNeeded(syncEventData: AddonModGlossaryAutoSyncData): boolean { return !!this.glossary && syncEventData.glossaryId == this.glossary.id && syncEventData.userId == CoreSites.getCurrentSiteUserId(); } /** * Change fetch mode. * * @param mode New mode. */ protected switchMode(mode: AddonModGlossaryFetchMode): void { this.fetchMode = mode; this.isSearch = false; switch (mode) { case 'author_all': // Browse by author. this.viewMode = 'author'; this.fetchFunction = AddonModGlossary.getEntriesByAuthor.bind( AddonModGlossary.instance, this.glossary!.id, 'ALL', 'LASTNAME', 'ASC', ); this.fetchInvalidate = AddonModGlossary.invalidateEntriesByAuthor.bind( AddonModGlossary.instance, this.glossary!.id, 'ALL', 'LASTNAME', 'ASC', ); this.getDivider = (entry) => entry.userfullname; this.showDivider = (entry, previous) => !previous || entry.userid != previous.userid; break; case 'cat_all': // Browse by category. this.viewMode = 'cat'; this.fetchFunction = AddonModGlossary.getEntriesByCategory.bind( AddonModGlossary.instance, this.glossary!.id, AddonModGlossaryProvider.SHOW_ALL_CATEGORIES, ); this.fetchInvalidate = AddonModGlossary.invalidateEntriesByCategory.bind( AddonModGlossary.instance, this.glossary!.id, AddonModGlossaryProvider.SHOW_ALL_CATEGORIES, ); this.getDivider = (entry: AddonModGlossaryEntryWithCategory) => entry.categoryname || ''; this.showDivider = (entry, previous) => !previous || this.getDivider!(entry) != this.getDivider!(previous); break; case 'newest_first': // Newest first. this.viewMode = 'date'; this.fetchFunction = AddonModGlossary.getEntriesByDate.bind( AddonModGlossary.instance, this.glossary!.id, 'CREATION', 'DESC', ); this.fetchInvalidate = AddonModGlossary.invalidateEntriesByDate.bind( AddonModGlossary.instance, this.glossary!.id, 'CREATION', 'DESC', ); this.getDivider = undefined; this.showDivider = () => false; break; case 'recently_updated': // Recently updated. this.viewMode = 'date'; this.fetchFunction = AddonModGlossary.getEntriesByDate.bind( AddonModGlossary.instance, this.glossary!.id, 'UPDATE', 'DESC', ); this.fetchInvalidate = AddonModGlossary.invalidateEntriesByDate.bind( AddonModGlossary.instance, this.glossary!.id, 'UPDATE', 'DESC', ); this.getDivider = undefined; this.showDivider = () => false; break; case 'letter_all': default: // Consider it is 'letter_all'. this.viewMode = 'letter'; this.fetchMode = 'letter_all'; this.fetchFunction = AddonModGlossary.getEntriesByLetter.bind( AddonModGlossary.instance, this.glossary!.id, 'ALL', ); this.fetchInvalidate = AddonModGlossary.invalidateEntriesByLetter.bind( AddonModGlossary.instance, this.glossary!.id, 'ALL', ); this.getDivider = (entry) => { // Try to get the first letter without HTML tags. const noTags = CoreTextUtils.cleanTags(entry.concept); return (noTags || entry.concept).substr(0, 1).toUpperCase(); }; this.showDivider = (entry, previous) => !previous || this.getDivider!(entry) != this.getDivider!(previous); break; } } /** * Convenience function to load more entries. * * @param infiniteComplete Infinite scroll complete function. Only used from core-infinite-loading. * @return Promise resolved when done. */ async loadMoreEntries(infiniteComplete?: () => void): Promise { try { await this.fetchEntries(true); } catch (error) { this.loadMoreError = true; CoreDomUtils.showErrorModalDefault(error, 'addon.mod_glossary.errorloadingentries', true); } finally { infiniteComplete && infiniteComplete(); } } /** * Show the mode picker menu. * * @param event Event. */ async openModePicker(event: MouseEvent): Promise { const popover = await PopoverController.create({ component: AddonModGlossaryModePickerPopoverComponent, componentProps: { browseModes: this.glossary!.browsemodes, selectedMode: this.isSearch ? '' : this.fetchMode, }, event, }); await popover.present(); const result = await popover.onDidDismiss(); const mode = result.data; if (mode) { if (mode !== this.fetchMode) { this.changeFetchMode(mode); } else if (this.isSearch) { this.toggleSearch(); } } } /** * Toggles between search and fetch mode. */ toggleSearch(): void { if (this.isSearch) { this.isSearch = false; this.entries.setOnlineEntries(this.fetchedEntries, this.fetchedEntriesCanLoadMore); this.switchMode(this.fetchMode!); } else { // Search for entries. The fetch function will be set when searching. this.getDivider = undefined; this.showDivider = () => false; this.isSearch = true; this.fetchedEntries = this.entries.onlineEntries; this.fetchedEntriesCanLoadMore = !this.entries.completed; this.entries.setItems([], false); } } /** * Change fetch mode. * * @param mode Mode. */ changeFetchMode(mode: AddonModGlossaryFetchMode): void { this.isSearch = false; this.loadingMessage = Translate.instant('core.loading'); this.content?.scrollToTop(); this.switchMode(mode); this.loaded = false; this.loadContent(); } /** * Opens new entry editor. */ openNewEntry(): void { this.entries.select({ newEntry: true }); } /** * Search entries. * * @param query Text entered on the search box. */ search(query: string): void { this.loadingMessage = Translate.instant('core.searching'); this.fetchFunction = AddonModGlossary.getEntriesBySearch.bind( AddonModGlossary.instance, this.glossary!.id, query, true, 'CONCEPT', 'ASC', ); this.fetchInvalidate = AddonModGlossary.invalidateEntriesBySearch.bind( AddonModGlossary.instance, this.glossary!.id, query, true, 'CONCEPT', 'ASC', ); this.loaded = false; this.loadContent(); } /** * @inheritdoc */ ngOnDestroy(): void { super.ngOnDestroy(); this.addEntryObserver?.off(); this.ratingOfflineObserver?.off(); this.ratingSyncObserver?.off(); } } /** * Type to select the new entry form. */ type NewEntryForm = { newEntry: true }; /** * Type of items that can be held by the entries manager. */ type EntryItem = AddonModGlossaryEntry | AddonModGlossaryOfflineEntry | NewEntryForm; /** * Entries manager. */ class AddonModGlossaryEntriesManager extends CorePageItemsListManager { onlineEntries: AddonModGlossaryEntry[] = []; offlineEntries: AddonModGlossaryOfflineEntry[] = []; protected glossaryPathPrefix: string; protected component: AddonModGlossaryIndexComponent; constructor( pageComponent: unknown, component: AddonModGlossaryIndexComponent, glossaryPathPrefix: string, ) { super(pageComponent); this.component = component; this.glossaryPathPrefix = glossaryPathPrefix; } /** * Type guard to infer NewEntryForm objects. * * @param entry Item to check. * @return Whether the item is a new entry form. */ isNewEntryForm(entry: EntryItem): entry is NewEntryForm { return 'newEntry' in entry; } /** * Type guard to infer entry objects. * * @param entry Item to check. * @return Whether the item is an offline entry. */ isOfflineEntry(entry: EntryItem): entry is AddonModGlossaryOfflineEntry { return !this.isNewEntryForm(entry) && !this.isOnlineEntry(entry); } /** * Type guard to infer entry objects. * * @param entry Item to check. * @return Whether the item is an offline entry. */ isOnlineEntry(entry: EntryItem): entry is AddonModGlossaryEntry { return 'id' in entry; } /** * Update online entries items. * * @param onlineEntries Online entries. */ setOnlineEntries(onlineEntries: AddonModGlossaryEntry[], hasMoreItems: boolean = false): void { this.setItems(( this.offlineEntries).concat(onlineEntries), hasMoreItems); this.onlineEntries.concat(onlineEntries); } /** * Update offline entries items. * * @param offlineEntries Offline entries. */ setOfflineEntries(offlineEntries: AddonModGlossaryOfflineEntry[]): void { this.setItems(( offlineEntries).concat(this.onlineEntries), this.hasMoreItems); this.offlineEntries = offlineEntries; } /** * @inheritdoc */ setItems(entries: EntryItem[], hasMoreItems: boolean = false): void { super.setItems(entries, hasMoreItems); this.onlineEntries = []; this.offlineEntries = []; this.items.forEach(entry => { if (this.isOfflineEntry(entry)) { this.offlineEntries.push(entry); } else if (this.isOnlineEntry(entry)) { this.onlineEntries.push(entry); } }); } /** * @inheritdoc */ resetItems(): void { super.resetItems(); this.onlineEntries = []; this.offlineEntries = []; } /** * @inheritdoc */ protected getItemPath(entry: EntryItem): string { if (this.isOnlineEntry(entry)) { return `${this.glossaryPathPrefix}entry/${entry.id}`; } if (this.isOfflineEntry(entry)) { return `${this.glossaryPathPrefix}edit/${entry.timecreated}`; } return `${this.glossaryPathPrefix}edit/0`; } /** * @inheritdoc */ getItemQueryParams(entry: EntryItem): Params { const params: Params = { cmId: this.component.module.id, courseId: this.component.courseId, }; if (this.isOfflineEntry(entry)) { params.concept = entry.concept; } return params; } /** * @inheritdoc */ protected getDefaultItem(): EntryItem | null { return this.onlineEntries[0] || null; } } export type AddonModGlossaryFetchMode = 'author_all' | 'cat_all' | 'newest_first' | 'recently_updated' | 'letter_all';