Noel De Martin ce09ee8a6c MOBILE-2652 glossary: Refactor navigation
Instead of showing the form for offline entries, we're showing them as normal entries and the form is only used for creating new entries. Additionally, the form won't be shown as a split view item any longer, it will always open a new page.
2023-04-13 17:45:22 +02:00

495 lines
16 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 } from '@angular/router';
import { CoreListItemsManager } from '@classes/items-management/list-items-manager';
import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker';
import { CorePromisedValue } from '@classes/promised-value';
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 { CoreNavigator } from '@services/navigator';
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,
GLOSSARY_ENTRY_ADDED,
GLOSSARY_ENTRY_DELETED,
} from '../../services/glossary';
import { AddonModGlossaryOfflineEntry } from '../../services/glossary-offline';
import {
AddonModGlossaryAutoSyncedData,
AddonModGlossarySyncResult,
GLOSSARY_AUTO_SYNCED,
} 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',
styleUrls: ['index.scss'],
})
export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivityComponent
implements OnInit, AfterViewInit, OnDestroy {
@ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent;
component = AddonModGlossaryProvider.COMPONENT;
moduleName = 'glossary';
canAdd = false;
loadMoreError = false;
loadingMessage: string;
promisedEntries: CorePromisedValue<AddonModGlossaryEntriesManager>;
protected hasOfflineEntries = false;
protected hasOfflineRatings = false;
protected syncEventName = GLOSSARY_AUTO_SYNCED;
protected fetchedEntriesCanLoadMore = false;
protected fetchedEntries: AddonModGlossaryEntry[] = [];
protected sourceUnsubscribe?: () => void;
protected observers?: CoreEventObserver[];
protected checkCompletionAfterLog = false; // Use CoreListItemsManager log system instead.
getDivider?: (entry: AddonModGlossaryEntry) => string;
showDivider: (entry: AddonModGlossaryEntry, previous?: AddonModGlossaryEntry) => boolean = () => false;
constructor(
public route: ActivatedRoute,
protected content?: IonContent,
@Optional() protected courseContentsPage?: CoreCourseContentsPage,
) {
super('AddonModGlossaryIndexComponent', content, courseContentsPage);
this.loadingMessage = Translate.instant('core.loading');
this.promisedEntries = new CorePromisedValue();
}
get entries(): AddonModGlossaryEntriesManager | null {
return this.promisedEntries.value;
}
get glossary(): AddonModGlossaryGlossary | undefined {
return this.entries?.getSource().glossary;
}
get isSearch(): boolean {
return this.entries?.getSource().isSearch ?? false;
}
get hasSearched(): boolean {
return this.entries?.getSource().hasSearched ?? false;
}
/**
* @inheritdoc
*/
async ngOnInit(): Promise<void> {
await super.ngOnInit();
// Initialize entries manager.
const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource(
AddonModGlossaryEntriesSource,
[this.courseId, this.module.id, this.courseContentsPage ? `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` : ''],
);
this.promisedEntries.resolve(new AddonModGlossaryEntriesManager(source, this));
this.sourceUnsubscribe = source.addListener({
onItemsUpdated: (items) => {
this.hasOfflineEntries = !!items.find(item => source.isOfflineEntry(item));
this.hasOffline = this.hasOfflineEntries || this.hasOfflineRatings;
},
});
// When an entry is added, we reload the data.
this.observers = [
CoreEvents.on(GLOSSARY_ENTRY_ADDED, ({ glossaryId }) => {
if (this.glossary?.id !== glossaryId) {
return;
}
// Check completion since it could be configured to complete once the user adds a new entry.
this.checkCompletion();
this.showLoadingAndRefresh(false);
}),
CoreEvents.on(GLOSSARY_ENTRY_DELETED, ({ glossaryId }) => {
if (this.glossary?.id !== glossaryId) {
return;
}
this.showLoadingAndRefresh(false);
}),
];
// Listen for offline ratings saved and synced.
this.observers.push(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.hasOffline = true;
}
}));
this.observers.push(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;
this.hasOffline = this.hasOfflineEntries;
}
}));
}
/**
* @inheritdoc
*/
async ngAfterViewInit(): Promise<void> {
const entries = await this.promisedEntries;
await this.loadContent(false, true);
await entries.start(this.splitView);
}
/**
* @inheritdoc
*/
protected async fetchContent(refresh = false, sync = false, showErrors = false): Promise<void> {
const entries = await this.promisedEntries;
await 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 (!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 ? entries.reload() : entries.load(),
]);
this.hasOfflineRatings = hasOfflineRatings;
this.hasOffline = this.hasOfflineEntries || this.hasOfflineRatings;
}
/**
* @inheritdoc
*/
protected async invalidateContent(): Promise<void> {
await this.entries?.getSource().invalidateCache();
}
/**
* @inheritdoc
*/
protected sync(): Promise<AddonModGlossarySyncResult> {
return AddonModGlossaryPrefetchHandler.sync(this.module, this.courseId);
}
/**
* Compares sync event data with current data to check if refresh content is needed.
*
* @param syncEventData Data receiven on sync observer.
* @returns True if refresh is needed, false otherwise.
*/
protected isRefreshSyncNeeded(syncEventData: AddonModGlossaryAutoSyncedData): 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).substring(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.
* @returns Promise resolved when done.
*/
async loadMoreEntries(infiniteComplete?: () => void): Promise<void> {
const entries = await this.promisedEntries;
try {
this.loadMoreError = false;
await 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<void> {
if (!this.glossary) {
return;
}
const entries = await this.promisedEntries;
const previousMode = entries.getSource().fetchMode;
const newMode = await CoreDomUtils.openPopover<AddonModGlossaryFetchMode>({
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.entries) {
return;
}
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.showLoading = true;
this.loadContent();
}
/**
* Opens new entry editor.
*/
openNewEntry(): void {
CoreNavigator.navigate(
this.splitView.outletActivated
? '../new'
: './entry/new',
);
}
/**
* Search entries.
*
* @param query Text entered on the search box.
*/
search(query: string): void {
this.loadingMessage = Translate.instant('core.searching');
this.showLoading = true;
this.entries?.getSource().search(query);
this.loadContent();
}
/**
* @inheritdoc
*/
ngOnDestroy(): void {
super.ngOnDestroy();
this.observers?.forEach(observer => observer.off());
this.sourceUnsubscribe?.call(null);
this.entries?.destroy();
}
}
/**
* Entries manager.
*/
class AddonModGlossaryEntriesManager extends CoreListItemsManager<AddonModGlossaryEntryItem, AddonModGlossaryEntriesSource> {
page: AddonModGlossaryIndexComponent;
constructor(source: AddonModGlossaryEntriesSource, page: AddonModGlossaryIndexComponent) {
super(source, page.route.component);
this.page = page;
}
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<void> {
const glossary = this.getSource().glossary;
const viewMode = this.getSource().viewMode;
if (!glossary || !viewMode) {
return;
}
try {
await AddonModGlossary.logView(glossary.id, viewMode, glossary.name);
CoreCourse.checkModuleCompletion(this.page.courseId, this.page.module.completiondata);
} catch {
// Ignore errors.
}
}
/**
* Check whether there is any entry in the items.
*
* @returns Whether there is an entry.
*/
get hasEntries(): boolean {
return this.getSource().onlineEntries.length > 0 || this.getSource().offlineEntries.length > 0;
}
}