// (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 } from '@angular/router'; import { CoreListItemsManager } from '@classes/items-management/list-items-manager'; import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; 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 { Translate } from '@singletons'; import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { AddonModGlossaryEntriesSource, AddonModGlossaryEntryItem, AddonModGlossaryFetchMode, } from '../../classes/glossary-entries-source'; import { AddonModGlossary, AddonModGlossaryEntry, AddonModGlossaryEntryWithCategory, AddonModGlossaryGlossary, AddonModGlossaryProvider, } from '../../services/glossary'; import { 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'; canAdd = false; loadMoreError = false; loadingMessage: string; entries!: AddonModGlossaryEntriesManager; hasOfflineRatings = false; protected syncEventName = AddonModGlossarySyncProvider.AUTO_SYNCED; protected addEntryObserver?: CoreEventObserver; protected fetchedEntriesCanLoadMore = false; protected fetchedEntries: AddonModGlossaryEntry[] = []; protected sourceUnsubscribe?: () => void; protected ratingOfflineObserver?: CoreEventObserver; protected ratingSyncObserver?: CoreEventObserver; getDivider?: (entry: AddonModGlossaryEntry) => string; showDivider: (entry: AddonModGlossaryEntry, previous?: AddonModGlossaryEntry) => boolean = () => false; constructor( protected route: ActivatedRoute, protected content?: IonContent, @Optional() protected courseContentsPage?: CoreCourseContentsPage, ) { super('AddonModGlossaryIndexComponent', content, courseContentsPage); this.loadingMessage = Translate.instant('core.loading'); } get glossary(): AddonModGlossaryGlossary | undefined { return this.entries.getSource().glossary; } get isSearch(): boolean { return this.entries.getSource().isSearch; } get hasSearched(): boolean { return this.entries.getSource().hasSearched; } /** * @inheritdoc */ async ngOnInit(): Promise { await super.ngOnInit(); // Initialize entries manager. const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource( AddonModGlossaryEntriesSource, [this.courseId, this.module.id, this.courseContentsPage ? `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` : ''], ); this.entries = new AddonModGlossaryEntriesManager( source, this.route.component, ); this.sourceUnsubscribe = source.addListener({ onItemsUpdated: items => this.hasOffline = !!items.find(item => source.isOfflineEntry(item)), }); // 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; } await this.entries.start(this.splitView); try { 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 { await this.entries.getSource().loadGlossary(); if (!this.glossary) { return; } this.description = this.glossary.intro || this.description; this.canAdd = !!this.glossary.canaddentry || false; this.dataRetrieved.emit(this.glossary); if (!this.entries.getSource().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), refresh ? this.entries.reload() : this.entries.load(), ]); this.hasOfflineRatings = hasOfflineRatings; } finally { this.fillContextMenu(refresh); } } /** * @inheritdoc */ protected async invalidateContent(): Promise { await this.entries.getSource().invalidateCache(); } /** * 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.entries.getSource().switchMode(mode); switch (mode) { case 'author_all': // Browse by author. this.getDivider = (entry) => entry.userfullname; this.showDivider = (entry, previous) => !previous || entry.userid != previous.userid; break; case 'cat_all': { // Browse by category. const getDivider = (entry: AddonModGlossaryEntryWithCategory) => entry.categoryname || ''; this.getDivider = getDivider; this.showDivider = (entry, previous) => !previous || getDivider(entry) != getDivider(previous); break; } case 'newest_first': // Newest first. this.getDivider = undefined; this.showDivider = () => false; break; case 'recently_updated': // Recently updated. this.getDivider = undefined; this.showDivider = () => false; break; case 'letter_all': default: { // Consider it is 'letter_all'. const 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.getDivider = getDivider; this.showDivider = (entry, previous) => !previous || getDivider(entry) != 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 { this.loadMoreError = false; await this.entries.load(); } 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 { if (!this.glossary) { return; } const previousMode = this.entries.getSource().fetchMode; const newMode = await CoreDomUtils.openPopover({ component: AddonModGlossaryModePickerPopoverComponent, componentProps: { browseModes: this.glossary.browsemodes, selectedMode: this.isSearch ? '' : previousMode, }, event, }); if (!newMode) { return; } if (newMode !== previousMode) { this.changeFetchMode(newMode); return; } if (this.isSearch) { this.toggleSearch(); return; } } /** * Toggles between search and fetch mode. */ toggleSearch(): void { if (this.isSearch) { const fetchMode = this.entries.getSource().fetchMode; fetchMode && this.switchMode(fetchMode); this.entries.getSource().stopSearch(this.fetchedEntries, this.fetchedEntriesCanLoadMore); return; } // Search for entries. The fetch function will be set when searching. this.fetchedEntries = this.entries.getSource().onlineEntries; this.fetchedEntriesCanLoadMore = !this.entries.completed; this.getDivider = undefined; this.showDivider = () => false; this.entries.reset(); this.entries.getSource().startSearch(); } /** * Change fetch mode. * * @param mode Mode. */ changeFetchMode(mode: AddonModGlossaryFetchMode): void { 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(AddonModGlossaryEntriesSource.NEW_ENTRY); } /** * Search entries. * * @param query Text entered on the search box. */ search(query: string): void { this.loadingMessage = Translate.instant('core.searching'); this.loaded = false; this.entries.getSource().search(query); this.loadContent(); } /** * @inheritdoc */ ngOnDestroy(): void { super.ngOnDestroy(); this.addEntryObserver?.off(); this.ratingOfflineObserver?.off(); this.ratingSyncObserver?.off(); this.sourceUnsubscribe?.call(null); this.entries.destroy(); } } /** * Entries manager. */ class AddonModGlossaryEntriesManager extends CoreListItemsManager { get offlineEntries(): AddonModGlossaryOfflineEntry[] { return this.getSource().offlineEntries; } get onlineEntries(): AddonModGlossaryEntry[] { return this.getSource().onlineEntries; } /** * @inheritdoc */ protected getDefaultItem(): AddonModGlossaryEntryItem | null { return this.getSource().onlineEntries[0] || null; } /** * @inheritdoc */ protected async logActivity(): Promise { const glossary = this.getSource().glossary; const viewMode = this.getSource().viewMode; if (!glossary || !viewMode) { return; } await AddonModGlossary.logView(glossary.id, viewMode, glossary.name); } }