MOBILE-3926 glossary: Entries swipe navigation

main
Noel De Martin 2021-11-25 13:00:58 +01:00
parent e8d0026995
commit 70ff7a375a
11 changed files with 812 additions and 475 deletions

View File

@ -0,0 +1,381 @@
// (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 { Params } from '@angular/router';
import { CoreItemsManagerSource } from '@classes/items-management/items-manager-source';
import {
AddonModGlossary,
AddonModGlossaryEntry,
AddonModGlossaryGetEntriesOptions,
AddonModGlossaryGetEntriesWSResponse,
AddonModGlossaryGlossary,
AddonModGlossaryProvider,
} from '../services/glossary';
import { AddonModGlossaryOffline, AddonModGlossaryOfflineEntry } from '../services/glossary-offline';
/**
* Provides a collection of glossary entries.
*/
export class AddonModGlossaryEntriesSource extends CoreItemsManagerSource<AddonModGlossaryEntryItem> {
static readonly NEW_ENTRY: AddonModGlossaryNewEntryForm = { newEntry: true };
readonly COURSE_ID: number;
readonly CM_ID: number;
readonly GLOSSARY_PATH_PREFIX: string;
isSearch = false;
hasSearched = false;
fetchMode?: AddonModGlossaryFetchMode;
viewMode?: string;
glossary?: AddonModGlossaryGlossary;
onlineEntries: AddonModGlossaryEntry[] = [];
offlineEntries: AddonModGlossaryOfflineEntry[] = [];
protected fetchFunction?: (options?: AddonModGlossaryGetEntriesOptions) => AddonModGlossaryGetEntriesWSResponse;
protected fetchInvalidate?: () => Promise<void>;
constructor(courseId: number, cmId: number, glossaryPathPrefix: string) {
super();
this.COURSE_ID = courseId;
this.CM_ID = cmId;
this.GLOSSARY_PATH_PREFIX = glossaryPathPrefix;
}
/**
* Type guard to infer NewEntryForm objects.
*
* @param entry Item to check.
* @return Whether the item is a new entry form.
*/
isNewEntryForm(entry: AddonModGlossaryEntryItem): entry is AddonModGlossaryNewEntryForm {
return 'newEntry' in entry;
}
/**
* Type guard to infer entry objects.
*
* @param entry Item to check.
* @return Whether the item is an offline entry.
*/
isOnlineEntry(entry: AddonModGlossaryEntryItem): entry is AddonModGlossaryEntry {
return 'id' in entry;
}
/**
* Type guard to infer entry objects.
*
* @param entry Item to check.
* @return Whether the item is an offline entry.
*/
isOfflineEntry(entry: AddonModGlossaryEntryItem): entry is AddonModGlossaryOfflineEntry {
return !this.isNewEntryForm(entry) && !this.isOnlineEntry(entry);
}
/**
* @inheritdoc
*/
getItemPath(entry: AddonModGlossaryEntryItem): string {
if (this.isOnlineEntry(entry)) {
return `${this.GLOSSARY_PATH_PREFIX}entry/${entry.id}`;
}
if (this.isOfflineEntry(entry)) {
return `${this.GLOSSARY_PATH_PREFIX}edit/${entry.timecreated}`;
}
return `${this.GLOSSARY_PATH_PREFIX}edit/0`;
}
/**
* @inheritdoc
*/
getItemQueryParams(entry: AddonModGlossaryEntryItem): Params {
const params: Params = {
cmId: this.CM_ID,
courseId: this.COURSE_ID,
};
if (this.isOfflineEntry(entry)) {
params.concept = entry.concept;
}
return params;
}
/**
* @inheritdoc
*/
getPagesLoaded(): number {
if (this.items === null) {
return 0;
}
return Math.ceil(this.onlineEntries.length / this.getPageLength());
}
/**
* Start searching.
*/
startSearch(): void {
this.isSearch = true;
}
/**
* Stop searching and restore unfiltered collection.
*
* @param cachedOnlineEntries Cached online entries.
* @param hasMoreOnlineEntries Whether there were more online entries.
*/
stopSearch(cachedOnlineEntries: AddonModGlossaryEntry[], hasMoreOnlineEntries: boolean): void {
if (!this.fetchMode) {
return;
}
this.isSearch = false;
this.hasSearched = false;
this.onlineEntries = cachedOnlineEntries;
this.hasMoreItems = hasMoreOnlineEntries;
}
/**
* Set search query.
*
* @param query Search query.
*/
search(query: string): void {
if (!this.glossary) {
return;
}
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.hasSearched = true;
}
/**
* Load glossary.
*/
async loadGlossary(): Promise<void> {
this.glossary = await AddonModGlossary.getGlossary(this.COURSE_ID, this.CM_ID);
}
/**
* Invalidate glossary cache.
*/
async invalidateCache(): Promise<void> {
await Promise.all([
AddonModGlossary.invalidateCourseGlossaries(this.COURSE_ID),
this.fetchInvalidate && this.fetchInvalidate(),
this.glossary && AddonModGlossary.invalidateCategories(this.glossary.id),
]);
}
/**
* Change fetch mode.
*
* @param mode New mode.
*/
switchMode(mode: AddonModGlossaryFetchMode): void {
if (!this.glossary) {
throw new Error('Can\'t switch entries mode without a glossary!');
}
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',
);
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,
);
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',
);
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',
);
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',
);
break;
}
}
/**
* @inheritdoc
*/
protected async loadPageItems(page: number): Promise<{ items: AddonModGlossaryEntryItem[]; hasMoreItems: boolean }> {
const glossary = this.glossary;
const fetchFunction = this.fetchFunction;
if (!glossary || !fetchFunction) {
throw new Error('Can\'t load entries without glossary or fetch function');
}
const entries: AddonModGlossaryEntryItem[] = [];
if (page === 0) {
const offlineEntries = await AddonModGlossaryOffline.getGlossaryNewEntries(glossary.id);
offlineEntries.sort((a, b) => a.concept.localeCompare(b.concept));
entries.push(AddonModGlossaryEntriesSource.NEW_ENTRY);
entries.push(...offlineEntries);
}
const from = page * this.getPageLength();
const pageEntries = await fetchFunction({ from, cmId: this.CM_ID });
entries.push(...pageEntries.entries);
return {
items: entries,
hasMoreItems: from + pageEntries.entries.length < pageEntries.count,
};
}
/**
* @inheritdoc
*/
protected getPageLength(): number {
return AddonModGlossaryProvider.LIMIT_ENTRIES;
}
/**
* @inheritdoc
*/
protected setItems(entries: AddonModGlossaryEntryItem[], hasMoreItems: boolean): void {
this.onlineEntries = [];
this.offlineEntries = [];
entries.forEach(entry => {
this.isOnlineEntry(entry) && this.onlineEntries.push(entry);
this.isOfflineEntry(entry) && this.offlineEntries.push(entry);
});
super.setItems(entries, hasMoreItems);
}
/**
* @inheritdoc
*/
reset(): void {
this.onlineEntries = [];
this.offlineEntries = [];
super.reset();
}
}
/**
* Type of items that can be held by the entries manager.
*/
export type AddonModGlossaryEntryItem = AddonModGlossaryEntry | AddonModGlossaryOfflineEntry | AddonModGlossaryNewEntryForm;
/**
* Type to select the new entry form.
*/
export type AddonModGlossaryNewEntryForm = { newEntry: true };
/**
* Fetch mode to sort entries.
*/
export type AddonModGlossaryFetchMode = 'author_all' | 'cat_all' | 'newest_first' | 'recently_updated' | 'letter_all';

View File

@ -0,0 +1,52 @@
// (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 { CoreSwipeItemsManager } from '@classes/items-management/swipe-items-manager';
import { AddonModGlossaryEntriesSource, AddonModGlossaryEntryItem } from './glossary-entries-source';
/**
* Helper to manage swiping within a collection of glossary entries.
*/
export abstract class AddonModGlossaryEntriesSwipeManager
extends CoreSwipeItemsManager<AddonModGlossaryEntryItem, AddonModGlossaryEntriesSource> {
/**
* @inheritdoc
*/
async navigateToNextItem(): Promise<void> {
let delta = -1;
const item = await this.getItemBy(-1);
if (item && this.getSource().isNewEntryForm(item)) {
delta--;
}
await this.navigateToItemBy(delta, 'back');
}
/**
* @inheritdoc
*/
async navigateToPreviousItem(): Promise<void> {
let delta = 1;
const item = await this.getItemBy(1);
if (item && this.getSource().isNewEntryForm(item)) {
delta++;
}
await this.navigateToItemBy(delta, 'forward');
}
}

View File

@ -54,7 +54,7 @@
[component]="component" [componentId]="componentId" [courseId]="courseId" [hasDataToSync]="hasOffline || hasOfflineRatings">
</core-course-module-info>
<ion-list *ngIf="!isSearch && entries.offlineEntries.length > 0">
<ion-list *ngIf="!isSearch && entries && entries.offlineEntries.length > 0">
<ion-item-divider>
<ion-label>
<h2>{{ 'addon.mod_glossary.entriestobesynced' | translate }}</h2>
@ -70,7 +70,7 @@
</ion-item>
</ion-list>
<ion-list *ngIf="entries.onlineEntries.length > 0">
<ion-list *ngIf="entries && entries.onlineEntries.length > 0">
<ng-container *ngFor="let entry of entries.onlineEntries; let index = index">
<ion-item-divider *ngIf="getDivider && showDivider(entry, entries.onlineEntries[index - 1])">
<ion-label>
@ -88,11 +88,11 @@
</ng-container>
</ion-list>
<core-empty-box *ngIf="entries.empty && (!isSearch || hasSearched)" icon="fas-list"
<core-empty-box *ngIf="(!entries || entries.empty) && (!isSearch || hasSearched)" icon="fas-list"
[message]="'addon.mod_glossary.noentriesfound' | translate">
</core-empty-box>
<core-infinite-loading [enabled]="!entries.completed" [error]="loadMoreError" (action)="loadMoreEntries($event)">
<core-infinite-loading [enabled]="entries && !entries.completed" [error]="loadMoreError" (action)="loadMoreEntries($event)">
</core-infinite-loading>
</core-loading>

View File

@ -14,8 +14,9 @@
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 { ActivatedRoute } from '@angular/router';
import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker';
import { CoreListItemsManager } from '@classes/items-management/list-items-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';
@ -29,16 +30,19 @@ 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,
AddonModGlossaryGetEntriesOptions,
AddonModGlossaryGetEntriesWSResponse,
AddonModGlossaryGlossary,
AddonModGlossaryProvider,
} from '../../services/glossary';
import { AddonModGlossaryOffline, AddonModGlossaryOfflineEntry } from '../../services/glossary-offline';
import { AddonModGlossaryOfflineEntry } from '../../services/glossary-offline';
import {
AddonModGlossaryAutoSyncData,
AddonModGlossarySyncProvider,
@ -63,23 +67,17 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
component = AddonModGlossaryProvider.COMPONENT;
moduleName = 'glossary';
isSearch = false;
hasSearched = false;
canAdd = false;
loadMoreError = false;
loadingMessage?: string;
entries: AddonModGlossaryEntriesManager;
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 sourceUnsubscribe?: () => void;
protected ratingOfflineObserver?: CoreEventObserver;
protected ratingSyncObserver?: CoreEventObserver;
@ -87,26 +85,47 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
showDivider: (entry: AddonModGlossaryEntry, previous?: AddonModGlossaryEntry) => boolean = () => false;
constructor(
route: ActivatedRoute,
protected route: ActivatedRoute,
protected content?: IonContent,
@Optional() courseContentsPage?: CoreCourseContentsPage,
@Optional() protected courseContentsPage?: CoreCourseContentsPage,
) {
super('AddonModGlossaryIndexComponent', content, courseContentsPage);
this.entries = new AddonModGlossaryEntriesManager(
route.component,
this,
courseContentsPage ? `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` : '',
);
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<void> {
super.ngOnInit();
await super.ngOnInit();
this.loadingMessage = Translate.instant('core.loading');
// Initialize entries manager.
const source = CoreItemsManagerSourcesTracker.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) => {
@ -143,11 +162,9 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
return;
}
this.entries.start(this.splitView);
await 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.
@ -159,14 +176,18 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
*/
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);
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.fetchMode) {
if (!this.entries.getSource().fetchMode) {
this.switchMode('letter_all');
}
@ -177,7 +198,7 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
const [hasOfflineRatings] = await Promise.all([
CoreRatingOffline.hasRatings('mod_glossary', 'entry', ContextLevel.MODULE, this.glossary.coursemodule),
this.fetchEntries(),
refresh ? this.entries.reload() : this.entries.loadNextPage(),
]);
this.hasOfflineRatings = hasOfflineRatings;
@ -186,59 +207,11 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
}
}
/**
* 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);
await this.entries.getSource().invalidateCache();
}
/**
@ -277,109 +250,50 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
* @param mode New mode.
*/
protected switchMode(mode: AddonModGlossaryFetchMode): void {
this.fetchMode = mode;
this.isSearch = false;
this.entries.getSource().switchMode(mode);
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':
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);
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.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:
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) => {
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.showDivider = (entry, previous) => !previous || this.getDivider!(entry) != this.getDivider!(previous);
this.getDivider = getDivider;
this.showDivider = (entry, previous) => !previous || getDivider(entry) != getDivider(previous);
break;
}
}
}
@ -391,7 +305,9 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
*/
async loadMoreEntries(infiniteComplete?: () => void): Promise<void> {
try {
await this.fetchEntries(true);
this.loadMoreError = false;
await this.entries.loadNextPage();
} catch (error) {
this.loadMoreError = true;
CoreDomUtils.showErrorModalDefault(error, 'addon.mod_glossary.errorloadingentries', true);
@ -406,21 +322,34 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
* @param event Event.
*/
async openModePicker(event: MouseEvent): Promise<void> {
const mode = await CoreDomUtils.openPopover<AddonModGlossaryFetchMode>({
if (!this.glossary) {
return;
}
const previousMode = this.entries.getSource().fetchMode;
const newMode = await CoreDomUtils.openPopover<AddonModGlossaryFetchMode>({
component: AddonModGlossaryModePickerPopoverComponent,
componentProps: {
browseModes: this.glossary!.browsemodes,
selectedMode: this.isSearch ? '' : this.fetchMode,
browseModes: this.glossary.browsemodes,
selectedMode: this.isSearch ? '' : previousMode,
},
event,
});
if (mode) {
if (mode !== this.fetchMode) {
this.changeFetchMode(mode);
} else if (this.isSearch) {
this.toggleSearch();
}
if (!newMode) {
return;
}
if (newMode !== previousMode) {
this.changeFetchMode(newMode);
return;
}
if (this.isSearch) {
this.toggleSearch();
return;
}
}
@ -429,20 +358,22 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
*/
toggleSearch(): void {
if (this.isSearch) {
this.isSearch = false;
this.hasSearched = 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;
const fetchMode = this.entries.getSource().fetchMode;
this.fetchedEntries = this.entries.onlineEntries;
this.fetchedEntriesCanLoadMore = !this.entries.completed;
this.entries.setItems([], false);
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();
}
/**
@ -451,7 +382,6 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
* @param mode Mode.
*/
changeFetchMode(mode: AddonModGlossaryFetchMode): void {
this.isSearch = false;
this.loadingMessage = Translate.instant('core.loading');
this.content?.scrollToTop();
this.switchMode(mode);
@ -463,7 +393,7 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
* Opens new entry editor.
*/
openNewEntry(): void {
this.entries.select({ newEntry: true });
this.entries.select(AddonModGlossaryEntriesSource.NEW_ENTRY);
}
/**
@ -473,24 +403,9 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
*/
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.hasSearched = true;
this.entries.getSource().search(query);
this.loadContent();
}
@ -503,154 +418,44 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
this.addEntryObserver?.off();
this.ratingOfflineObserver?.off();
this.ratingSyncObserver?.off();
this.sourceUnsubscribe?.call(null);
this.entries.destroy();
}
}
/**
* 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> {
class AddonModGlossaryEntriesManager extends CoreListItemsManager<AddonModGlossaryEntryItem, AddonModGlossaryEntriesSource> {
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;
get offlineEntries(): AddonModGlossaryOfflineEntry[] {
return this.getSource().offlineEntries;
}
/**
* 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);
}
/**
* Update offline entries items.
*
* @param offlineEntries Offline entries.
*/
setOfflineEntries(offlineEntries: AddonModGlossaryOfflineEntry[]): void {
this.setItems((<EntryItem[]> offlineEntries).concat(this.onlineEntries), this.hasMoreItems);
get onlineEntries(): AddonModGlossaryEntry[] {
return this.getSource().onlineEntries;
}
/**
* @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);
}
});
protected getDefaultItem(): AddonModGlossaryEntryItem | null {
return this.getSource().onlineEntries[0] || null;
}
/**
* @inheritdoc
*/
resetItems(): void {
super.resetItems();
this.onlineEntries = [];
this.offlineEntries = [];
}
protected async logActivity(): Promise<void> {
const glossary = this.getSource().glossary;
const viewMode = this.getSource().viewMode;
/**
* @inheritdoc
*/
protected getItemPath(entry: EntryItem): string {
if (this.isOnlineEntry(entry)) {
return `${this.glossaryPathPrefix}entry/${entry.id}`;
if (!glossary || !viewMode) {
return;
}
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;
await AddonModGlossary.logView(glossary.id, viewMode, glossary.name);
}
}
export type AddonModGlossaryFetchMode = 'author_all' | 'cat_all' | 'newest_first' | 'recently_updated' | 'letter_all';

View File

@ -14,7 +14,7 @@
import { Component, Input, OnInit } from '@angular/core';
import { PopoverController } from '@singletons';
import { AddonModGlossaryFetchMode } from '../index';
import { AddonModGlossaryFetchMode } from '../../classes/glossary-entries-source';
/**
* Component to display the mode picker.

View File

@ -51,10 +51,12 @@ const mainMenuRoutes: Routes = [
{
path: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/entry/:entryId`,
loadChildren: () => import('./pages/entry/entry.module').then(m => m.AddonModGlossaryEntryPageModule),
data: { swipeEnabled: false },
},
{
path: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/edit/:timecreated`,
loadChildren: () => import('./pages/edit/edit.module').then(m => m.AddonModGlossaryEditPageModule),
data: { swipeEnabled: false },
},
{
path: AddonModGlossaryModuleHandlerService.PAGE_NAME,
@ -65,10 +67,12 @@ const mainMenuRoutes: Routes = [
{
path: `${COURSE_CONTENTS_PATH}/${AddonModGlossaryModuleHandlerService.PAGE_NAME}/entry/:entryId`,
loadChildren: () => import('./pages/entry/entry.module').then(m => m.AddonModGlossaryEntryPageModule),
data: { glossaryPathPrefix: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` },
},
{
path: `${COURSE_CONTENTS_PATH}/${AddonModGlossaryModuleHandlerService.PAGE_NAME}/edit/:timecreated`,
loadChildren: () => import('./pages/edit/edit.module').then(m => m.AddonModGlossaryEditPageModule),
data: { glossaryPathPrefix: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` },
},
],
() => CoreScreen.isMobile,
@ -80,10 +84,12 @@ const courseContentsRoutes: Routes = conditionalRoutes(
{
path: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/entry/:entryId`,
loadChildren: () => import('./pages/entry/entry.module').then(m => m.AddonModGlossaryEntryPageModule),
data: { glossaryPathPrefix: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` },
},
{
path: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/edit/:timecreated`,
loadChildren: () => import('./pages/edit/edit.module').then(m => m.AddonModGlossaryEditPageModule),
data: { glossaryPathPrefix: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` },
},
],
() => CoreScreen.isTablet,

View File

@ -12,72 +12,75 @@
</ion-toolbar>
</ion-header>
<ion-content>
<core-loading [hideUntil]="loaded">
<form #editFormEl *ngIf="glossary">
<ion-item>
<ion-label position="stacked">{{ 'addon.mod_glossary.concept' | translate }}</ion-label>
<ion-input type="text" [placeholder]="'addon.mod_glossary.concept' | translate" [(ngModel)]="entry.concept" name="concept">
</ion-input>
</ion-item>
<ion-item>
<ion-label position="stacked">{{ 'addon.mod_glossary.definition' | translate }}</ion-label>
<core-rich-text-editor [control]="definitionControl" (contentChanged)="onDefinitionChange($event)"
[placeholder]="'addon.mod_glossary.definition' | translate" name="addon_mod_glossary_edit" [component]="component"
[componentId]="cmId" [autoSave]="true" contextLevel="module" [contextInstanceId]="cmId" elementId="definition_editor"
[draftExtraParams]="editorExtraParams">
</core-rich-text-editor>
</ion-item>
<ion-item *ngIf="categories.length > 0">
<ion-label position="stacked" id="addon-mod-glossary-categories-label">
{{ 'addon.mod_glossary.categories' | translate }}
</ion-label>
<ion-select [(ngModel)]="options.categories" multiple="true" aria-labelledby="addon-mod-glossary-categories-label"
interface="action-sheet" [placeholder]="'addon.mod_glossary.categories' | translate" name="categories"
[interfaceOptions]="{header: 'addon.mod_glossary.categories' | translate}">
<ion-select-option *ngFor="let category of categories" [value]="category.id">
{{ category.name }}
</ion-select-option>
</ion-select>
</ion-item>
<ion-item>
<ion-label position="stacked" id="addon-mod-glossary-aliases-label">
{{ 'addon.mod_glossary.aliases' | translate }}
</ion-label>
<ion-textarea [(ngModel)]="options.aliases" rows="1" [core-auto-rows]="options.aliases"
aria-labelledby="addon-mod-glossary-aliases-label" name="aliases">
</ion-textarea>
</ion-item>
<ion-item-divider>
<ion-label>
<h2>{{ 'addon.mod_glossary.attachment' | translate }}</h2>
</ion-label>
</ion-item-divider>
<core-attachments [files]="attachments" [component]="component" [componentId]="glossary.coursemodule" [allowOffline]="true"
[courseId]="courseId">
</core-attachments>
<ng-container *ngIf="glossary.usedynalink">
<core-swipe-navigation [manager]="entries">
<core-loading [hideUntil]="loaded">
<form #editFormEl *ngIf="glossary">
<ion-item>
<ion-label position="stacked">{{ 'addon.mod_glossary.concept' | translate }}</ion-label>
<ion-input type="text" [placeholder]="'addon.mod_glossary.concept' | translate" [(ngModel)]="entry.concept"
name="concept">
</ion-input>
</ion-item>
<ion-item>
<ion-label position="stacked">{{ 'addon.mod_glossary.definition' | translate }}</ion-label>
<core-rich-text-editor [control]="definitionControl" (contentChanged)="onDefinitionChange($event)"
[placeholder]="'addon.mod_glossary.definition' | translate" name="addon_mod_glossary_edit" [component]="component"
[componentId]="cmId" [autoSave]="true" contextLevel="module" [contextInstanceId]="cmId"
elementId="definition_editor" [draftExtraParams]="editorExtraParams">
</core-rich-text-editor>
</ion-item>
<ion-item *ngIf="categories.length > 0">
<ion-label position="stacked" id="addon-mod-glossary-categories-label">
{{ 'addon.mod_glossary.categories' | translate }}
</ion-label>
<ion-select [(ngModel)]="options.categories" multiple="true" aria-labelledby="addon-mod-glossary-categories-label"
interface="action-sheet" [placeholder]="'addon.mod_glossary.categories' | translate" name="categories"
[interfaceOptions]="{header: 'addon.mod_glossary.categories' | translate}">
<ion-select-option *ngFor="let category of categories" [value]="category.id">
{{ category.name }}
</ion-select-option>
</ion-select>
</ion-item>
<ion-item>
<ion-label position="stacked" id="addon-mod-glossary-aliases-label">
{{ 'addon.mod_glossary.aliases' | translate }}
</ion-label>
<ion-textarea [(ngModel)]="options.aliases" rows="1" [core-auto-rows]="options.aliases"
aria-labelledby="addon-mod-glossary-aliases-label" name="aliases">
</ion-textarea>
</ion-item>
<ion-item-divider>
<ion-label>
<h2>{{ 'addon.mod_glossary.linking' | translate }}</h2>
<h2>{{ 'addon.mod_glossary.attachment' | translate }}</h2>
</ion-label>
</ion-item-divider>
<ion-item class="ion-text-wrap">
<ion-label>{{ 'addon.mod_glossary.entryusedynalink' | translate }}</ion-label>
<ion-toggle [(ngModel)]="options.usedynalink" name="usedynalink"></ion-toggle>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>{{ 'addon.mod_glossary.casesensitive' | translate }}</ion-label>
<ion-toggle [disabled]="!options.usedynalink" [(ngModel)]="options.casesensitive" name="casesensitive">
</ion-toggle>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>{{ 'addon.mod_glossary.fullmatch' | translate }}</ion-label>
<ion-toggle [disabled]="!options.usedynalink" [(ngModel)]="options.fullmatch" name="fullmatch"></ion-toggle>
</ion-item>
</ng-container>
<ion-button class="ion-margin" expand="block" [disabled]="!entry.concept || !entry.definition" (click)="save()">
{{ 'core.save' | translate }}
</ion-button>
</form>
</core-loading>
<core-attachments [files]="attachments" [component]="component" [componentId]="glossary.coursemodule" [allowOffline]="true"
[courseId]="courseId">
</core-attachments>
<ng-container *ngIf="glossary.usedynalink">
<ion-item-divider>
<ion-label>
<h2>{{ 'addon.mod_glossary.linking' | translate }}</h2>
</ion-label>
</ion-item-divider>
<ion-item class="ion-text-wrap">
<ion-label>{{ 'addon.mod_glossary.entryusedynalink' | translate }}</ion-label>
<ion-toggle [(ngModel)]="options.usedynalink" name="usedynalink"></ion-toggle>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>{{ 'addon.mod_glossary.casesensitive' | translate }}</ion-label>
<ion-toggle [disabled]="!options.usedynalink" [(ngModel)]="options.casesensitive" name="casesensitive">
</ion-toggle>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>{{ 'addon.mod_glossary.fullmatch' | translate }}</ion-label>
<ion-toggle [disabled]="!options.usedynalink" [(ngModel)]="options.fullmatch" name="fullmatch"></ion-toggle>
</ion-item>
</ng-container>
<ion-button class="ion-margin" expand="block" [disabled]="!entry.concept || !entry.definition" (click)="save()">
{{ 'core.save' | translate }}
</ion-button>
</form>
</core-loading>
</core-swipe-navigation>
</ion-content>

View File

@ -12,9 +12,11 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnInit, ViewChild, ElementRef, Optional } from '@angular/core';
import { Component, OnInit, ViewChild, ElementRef, Optional, OnDestroy } from '@angular/core';
import { FormControl } from '@angular/forms';
import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router';
import { CoreError } from '@classes/errors/error';
import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker';
import { CoreSplitViewComponent } from '@components/split-view/split-view';
import { CoreFileUploader, CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader';
import { CanLeave } from '@guards/can-leave';
@ -26,6 +28,8 @@ import { CoreTextUtils } from '@services/utils/text';
import { Translate } from '@singletons';
import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { CoreForms } from '@singletons/form';
import { AddonModGlossaryEntriesSource } from '../../classes/glossary-entries-source';
import { AddonModGlossaryEntriesSwipeManager } from '../../classes/glossary-entries-swipe-manager';
import {
AddonModGlossary,
AddonModGlossaryCategory,
@ -45,7 +49,7 @@ import { AddonModGlossaryOffline } from '../../services/glossary-offline';
selector: 'page-addon-mod-glossary-edit',
templateUrl: 'edit.html',
})
export class AddonModGlossaryEditPage implements OnInit, CanLeave {
export class AddonModGlossaryEditPage implements OnInit, OnDestroy, CanLeave {
@ViewChild('editFormEl') formElement?: ElementRef;
@ -64,6 +68,8 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave {
timecreated: 0,
};
entries?: AddonModGlossaryEditEntriesSwipeManager;
options = {
categories: <string[]> [],
aliases: '',
@ -80,18 +86,30 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave {
protected originalData?: AddonModGlossaryNewEntryWithFiles;
protected saved = false;
constructor(@Optional() protected splitView: CoreSplitViewComponent) {}
constructor(protected route: ActivatedRoute, @Optional() protected splitView: CoreSplitViewComponent) {}
/**
* Component being initialized.
*/
ngOnInit(): void {
async ngOnInit(): Promise<void> {
try {
const routeData = this.route.snapshot.data;
this.cmId = CoreNavigator.getRequiredRouteNumberParam('cmId');
this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId');
this.timecreated = CoreNavigator.getRequiredRouteNumberParam('timecreated');
this.concept = CoreNavigator.getRouteParam<string>('concept') || '';
this.editorExtraParams.timecreated = this.timecreated;
if (this.timecreated !== 0 && (routeData.swipeEnabled ?? true)) {
const source = CoreItemsManagerSourcesTracker.getOrCreateSource(
AddonModGlossaryEntriesSource,
[this.courseId, this.cmId, routeData.glossaryPathPrefix ?? ''],
);
this.entries = new AddonModGlossaryEditEntriesSwipeManager(source);
await this.entries.start();
}
} catch (error) {
CoreDomUtils.showErrorModal(error);
@ -103,6 +121,13 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave {
this.fetchData();
}
/**
* @inheritdoc
*/
ngOnDestroy(): void {
this.entries?.destroy();
}
/**
* Fetch required data.
*
@ -134,7 +159,11 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave {
* @return Promise resolved when done.
*/
protected async loadOfflineData(): Promise<void> {
const entry = await AddonModGlossaryOffline.getNewEntry(this.glossary!.id, this.concept, this.timecreated);
if (!this.glossary) {
return;
}
const entry = await AddonModGlossaryOffline.getNewEntry(this.glossary.id, this.concept, this.timecreated);
this.entry.concept = entry.concept || '';
this.entry.definition = entry.definition || '';
@ -159,7 +188,7 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave {
// Treat offline attachments if any.
if (entry.attachments?.offline) {
this.attachments = await AddonModGlossaryHelper.getStoredFiles(this.glossary!.id, entry.concept, entry.timecreated);
this.attachments = await AddonModGlossaryHelper.getStoredFiles(this.glossary.id, entry.concept, entry.timecreated);
this.originalData.files = this.attachments.slice();
}
@ -236,6 +265,10 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave {
definition = CoreTextUtils.formatHtmlLines(definition);
try {
if (!this.glossary) {
return;
}
// Upload attachments first if any.
const { saveOffline, attachmentsResult } = await this.uploadAttachments(timecreated);
@ -244,7 +277,7 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave {
categories: this.options.categories.join(','),
};
if (this.glossary!.usedynalink) {
if (this.glossary.usedynalink) {
options.usedynalink = this.options.usedynalink ? 1 : 0;
if (this.options.usedynalink) {
options.casesensitive = this.options.casesensitive ? 1 : 0;
@ -253,9 +286,9 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave {
}
if (saveOffline) {
if (this.entry && !this.glossary!.allowduplicatedentries) {
if (this.entry && !this.glossary.allowduplicatedentries) {
// Check if the entry is duplicated in online or offline mode.
const isUsed = await AddonModGlossary.isConceptUsed(this.glossary!.id, this.entry.concept, {
const isUsed = await AddonModGlossary.isConceptUsed(this.glossary.id, this.entry.concept, {
timeCreated: this.entry.timecreated,
cmId: this.cmId,
});
@ -268,7 +301,7 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave {
// Save entry in offline.
await AddonModGlossaryOffline.addNewEntry(
this.glossary!.id,
this.glossary.id,
this.entry.concept,
definition,
this.courseId,
@ -283,7 +316,7 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave {
// Try to send it to server.
// Don't allow offline if there are attachments since they were uploaded fine.
await AddonModGlossary.addEntry(
this.glossary!.id,
this.glossary.id,
this.entry.concept,
definition,
this.courseId,
@ -293,7 +326,7 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave {
timeCreated: timecreated,
discardEntry: this.entry,
allowOffline: !this.attachments.length,
checkDuplicates: !this.glossary!.allowduplicatedentries,
checkDuplicates: !this.glossary.allowduplicatedentries,
},
);
}
@ -303,12 +336,12 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave {
if (entryId) {
// Data sent to server, delete stored files (if any).
AddonModGlossaryHelper.deleteStoredFiles(this.glossary!.id, this.entry.concept, timecreated);
AddonModGlossaryHelper.deleteStoredFiles(this.glossary.id, this.entry.concept, timecreated);
CoreEvents.trigger(CoreEvents.ACTIVITY_DATA_SENT, { module: 'glossary' });
}
CoreEvents.trigger(AddonModGlossaryProvider.ADD_ENTRY_EVENT, {
glossaryId: this.glossary!.id,
glossaryId: this.glossary.id,
entryId: entryId,
}, CoreSites.getCurrentSiteId());
@ -342,7 +375,7 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave {
protected async uploadAttachments(
timecreated: number,
): Promise<{saveOffline: boolean; attachmentsResult?: number | CoreFileUploaderStoreFilesResult}> {
if (!this.attachments.length) {
if (!this.attachments.length || !this.glossary) {
return {
saveOffline: false,
};
@ -352,7 +385,7 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave {
const attachmentsResult = await CoreFileUploader.uploadOrReuploadFiles(
this.attachments,
AddonModGlossaryProvider.COMPONENT,
this.glossary!.id,
this.glossary.id,
);
return {
@ -362,7 +395,7 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave {
} catch {
// Cannot upload them in online, save them in offline.
const attachmentsResult = await AddonModGlossaryHelper.storeFiles(
this.glossary!.id,
this.glossary.id,
this.entry.concept,
timecreated,
this.attachments,
@ -387,3 +420,17 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave {
}
}
/**
* Helper to manage swiping within a collection of glossary entries.
*/
class AddonModGlossaryEditEntriesSwipeManager extends AddonModGlossaryEntriesSwipeManager {
/**
* @inheritdoc
*/
protected getSelectedItemPathFromRoute(route: ActivatedRouteSnapshot): string | null {
return `${this.getSource().GLOSSARY_PATH_PREFIX}edit/${route.params.timecreated}`;
}
}

View File

@ -12,73 +12,75 @@
</ion-toolbar>
</ion-header>
<ion-content>
<ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="doRefresh($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-swipe-navigation [manager]="entries">
<ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="doRefresh($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="loaded">
<ng-container *ngIf="entry && loaded">
<ion-item class="ion-text-wrap" *ngIf="showAuthor">
<core-user-avatar [user]="entry" slot="start"></core-user-avatar>
<ion-label>
<h2>
<core-format-text [text]="entry.concept" contextLevel="module" [contextInstanceId]="componentId"
[courseId]="courseId">
<core-loading [hideUntil]="loaded">
<ng-container *ngIf="entry && loaded">
<ion-item class="ion-text-wrap" *ngIf="showAuthor">
<core-user-avatar [user]="entry" slot="start"></core-user-avatar>
<ion-label>
<h2>
<core-format-text [text]="entry.concept" contextLevel="module" [contextInstanceId]="componentId"
[courseId]="courseId">
</core-format-text>
</h2>
<p>{{ entry.userfullname }}</p>
</ion-label>
<ion-note slot="end" *ngIf="showDate">{{ entry.timemodified | coreDateDayOrTime }}</ion-note>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="!showAuthor">
<ion-label>
<p class="item-heading">
<core-format-text [text]="entry.concept" contextLevel="module" [contextInstanceId]="componentId">
</core-format-text>
</p>
</ion-label>
<ion-note slot="end" *ngIf="showDate">{{ entry.timemodified | coreDateDayOrTime }}</ion-note>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
<core-format-text [component]="component" [componentId]="componentId" [text]="entry.definition"
contextLevel="module" [contextInstanceId]="componentId" [courseId]="courseId">
</core-format-text>
</h2>
<p>{{ entry.userfullname }}</p>
</ion-label>
<ion-note slot="end" *ngIf="showDate">{{ entry.timemodified | coreDateDayOrTime }}</ion-note>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="!showAuthor">
<ion-label>
<p class="item-heading">
<core-format-text [text]="entry.concept" contextLevel="module" [contextInstanceId]="componentId">
</core-format-text>
</p>
</ion-label>
<ion-note slot="end" *ngIf="showDate">{{ entry.timemodified | coreDateDayOrTime }}</ion-note>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
<core-format-text [component]="component" [componentId]="componentId" [text]="entry.definition" contextLevel="module"
[contextInstanceId]="componentId" [courseId]="courseId">
</core-format-text>
</ion-label>
</ion-item>
<div *ngIf="entry.attachment" lines="none">
<core-file *ngFor="let file of entry.attachments" [file]="file" [component]="component" [componentId]="componentId">
</core-file>
</div>
<ion-item class="ion-text-wrap" *ngIf="tagsEnabled && entry && entry.tags && entry.tags.length > 0">
<ion-label>
<div slot="start">{{ 'core.tag.tags' | translate }}:</div>
<core-tag-list [tags]="entry.tags"></core-tag-list>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="!entry.approved">
<ion-label>
<p><em>{{ 'addon.mod_glossary.entrypendingapproval' | translate }}</em></p>
</ion-label>
</ion-item>
<core-comments *ngIf="glossary && glossary.allowcomments && entry && entry.id > 0 && commentsEnabled" contextLevel="module"
[instanceId]="glossary.coursemodule" component="mod_glossary" [itemId]="entry.id" area="glossary_entry"
[courseId]="glossary.course" [showItem]="true">
</core-comments>
<core-rating-rate *ngIf="glossary && ratingInfo" [ratingInfo]="ratingInfo" contextLevel="module"
[instanceId]="glossary.coursemodule" [itemId]="entry.id" [itemSetId]="0" [courseId]="glossary.course"
[aggregateMethod]="glossary.assessed" [scaleId]="glossary.scale" [userId]="entry.userid" (onUpdate)="ratingUpdated()">
</core-rating-rate>
<core-rating-aggregate *ngIf="glossary && ratingInfo" [ratingInfo]="ratingInfo" contextLevel="module"
[instanceId]="glossary.coursemodule" [itemId]="entry.id" [courseId]="glossary.course" [aggregateMethod]="glossary.assessed"
[scaleId]="glossary.scale">
</core-rating-aggregate>
</ng-container>
</ion-label>
</ion-item>
<div *ngIf="entry.attachment" lines="none">
<core-file *ngFor="let file of entry.attachments" [file]="file" [component]="component" [componentId]="componentId">
</core-file>
</div>
<ion-item class="ion-text-wrap" *ngIf="tagsEnabled && entry && entry.tags && entry.tags.length > 0">
<ion-label>
<div slot="start">{{ 'core.tag.tags' | translate }}:</div>
<core-tag-list [tags]="entry.tags"></core-tag-list>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="!entry.approved">
<ion-label>
<p><em>{{ 'addon.mod_glossary.entrypendingapproval' | translate }}</em></p>
</ion-label>
</ion-item>
<core-comments *ngIf="glossary && glossary.allowcomments && entry && entry.id > 0 && commentsEnabled" contextLevel="module"
[instanceId]="glossary.coursemodule" component="mod_glossary" [itemId]="entry.id" area="glossary_entry"
[courseId]="glossary.course" [showItem]="true">
</core-comments>
<core-rating-rate *ngIf="glossary && ratingInfo" [ratingInfo]="ratingInfo" contextLevel="module"
[instanceId]="glossary.coursemodule" [itemId]="entry.id" [itemSetId]="0" [courseId]="glossary.course"
[aggregateMethod]="glossary.assessed" [scaleId]="glossary.scale" [userId]="entry.userid" (onUpdate)="ratingUpdated()">
</core-rating-rate>
<core-rating-aggregate *ngIf="glossary && ratingInfo" [ratingInfo]="ratingInfo" contextLevel="module"
[instanceId]="glossary.coursemodule" [itemId]="entry.id" [courseId]="glossary.course"
[aggregateMethod]="glossary.assessed" [scaleId]="glossary.scale">
</core-rating-aggregate>
</ng-container>
<ion-card *ngIf="!entry" class="core-warning-card">
<ion-item>
<ion-label>{{ 'addon.mod_glossary.errorloadingentry' | translate }}</ion-label>
</ion-item>
</ion-card>
</core-loading>
<ion-card *ngIf="!entry" class="core-warning-card">
<ion-item>
<ion-label>{{ 'addon.mod_glossary.errorloadingentry' | translate }}</ion-label>
</ion-item>
</ion-card>
</core-loading>
</core-swipe-navigation>
</ion-content>

View File

@ -12,7 +12,9 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnInit, ViewChild } from '@angular/core';
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router';
import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker';
import { CoreCommentsCommentsComponent } from '@features/comments/components/comments/comments';
import { CoreComments } from '@features/comments/services/comments';
import { CoreRatingInfo } from '@features/rating/services/rating';
@ -21,6 +23,8 @@ import { IonRefresher } from '@ionic/angular';
import { CoreNavigator } from '@services/navigator';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { AddonModGlossaryEntriesSource } from '../../classes/glossary-entries-source';
import { AddonModGlossaryEntriesSwipeManager } from '../../classes/glossary-entries-swipe-manager';
import {
AddonModGlossary,
AddonModGlossaryEntry,
@ -35,13 +39,14 @@ import {
selector: 'page-addon-mod-glossary-entry',
templateUrl: 'entry.html',
})
export class AddonModGlossaryEntryPage implements OnInit {
export class AddonModGlossaryEntryPage implements OnInit, OnDestroy {
@ViewChild(CoreCommentsCommentsComponent) comments?: CoreCommentsCommentsComponent;
component = AddonModGlossaryProvider.COMPONENT;
componentId?: number;
entry?: AddonModGlossaryEntry;
entries?: AddonModGlossaryEntryEntriesSwipeManager;
glossary?: AddonModGlossaryGlossary;
loaded = false;
showAuthor = false;
@ -53,15 +58,30 @@ export class AddonModGlossaryEntryPage implements OnInit {
protected entryId!: number;
constructor(protected route: ActivatedRoute) {}
/**
* @inheritdoc
*/
async ngOnInit(): Promise<void> {
try {
const routeData = this.route.snapshot.data;
this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId');
this.entryId = CoreNavigator.getRequiredRouteNumberParam('entryId');
this.tagsEnabled = CoreTag.areTagsAvailableInSite();
this.commentsEnabled = !CoreComments.areCommentsDisabledInSite();
if (routeData.swipeEnabled ?? true) {
const cmId = CoreNavigator.getRequiredRouteNumberParam('cmId');
const source = CoreItemsManagerSourcesTracker.getOrCreateSource(
AddonModGlossaryEntriesSource,
[this.courseId, cmId, routeData.glossaryPathPrefix ?? ''],
);
this.entries = new AddonModGlossaryEntryEntriesSwipeManager(source);
await this.entries.start();
}
} catch (error) {
CoreDomUtils.showErrorModal(error);
@ -73,16 +93,23 @@ export class AddonModGlossaryEntryPage implements OnInit {
try {
await this.fetchEntry();
if (!this.glossary) {
if (!this.glossary || !this.componentId) {
return;
}
await CoreUtils.ignoreErrors(AddonModGlossary.logEntryView(this.entryId, this.componentId!, this.glossary.name));
await CoreUtils.ignoreErrors(AddonModGlossary.logEntryView(this.entryId, this.componentId, this.glossary.name));
} finally {
this.loaded = true;
}
}
/**
* @inheritdoc
*/
ngOnDestroy(): void {
this.entries?.destroy();
}
/**
* Refresh the data.
*
@ -152,3 +179,17 @@ export class AddonModGlossaryEntryPage implements OnInit {
}
}
/**
* Helper to manage swiping within a collection of glossary entries.
*/
class AddonModGlossaryEntryEntriesSwipeManager extends AddonModGlossaryEntriesSwipeManager {
/**
* @inheritdoc
*/
protected getSelectedItemPathFromRoute(route: ActivatedRouteSnapshot): string | null {
return `${this.getSource().GLOSSARY_PATH_PREFIX}entry/${route.params.entryId}`;
}
}

View File

@ -37,9 +37,9 @@ export abstract class CoreItemsManagerSource<Item = unknown> {
return args.map(argument => String(argument)).join('-');
}
private items: Item[] | null = null;
private hasMoreItems = true;
private listeners: CoreItemsListSourceListener<Item>[] = [];
protected items: Item[] | null = null;
protected hasMoreItems = true;
protected listeners: CoreItemsListSourceListener<Item>[] = [];
/**
* Check whether any page has been loaded.