2021-05-03 10:15:25 +02:00

661 lines
22 KiB
TypeScript

// (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<void>;
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
const promises: Promise<void>[] = [];
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<AddonModGlossarySyncResult> {
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<void> {
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<void> {
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<AddonModGlossaryFetchMode>();
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<EntryItem> {
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((<EntryItem[]> this.offlineEntries).concat(onlineEntries), hasMoreItems);
this.onlineEntries.concat(onlineEntries);
}
/**
* Update offline entries items.
*
* @param offlineEntries Offline entries.
*/
setOfflineEntries(offlineEntries: AddonModGlossaryOfflineEntry[]): void {
this.setItems((<EntryItem[]> 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';