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.
main
Noel De Martin 2023-03-28 13:44:20 +02:00
parent f59cf0a1c5
commit ce09ee8a6c
16 changed files with 360 additions and 397 deletions

View File

@ -697,6 +697,7 @@
"addon.mod_glossary.definition": "glossary",
"addon.mod_glossary.deleteentry": "glossary",
"addon.mod_glossary.entriestobesynced": "local_moodlemobileapp",
"addon.mod_glossary.entry": "glossary",
"addon.mod_glossary.entrydeleted": "glossary",
"addon.mod_glossary.entrypendingapproval": "local_moodlemobileapp",
"addon.mod_glossary.entryusedynalink": "glossary",

View File

@ -29,8 +29,6 @@ import { AddonModGlossaryOffline, AddonModGlossaryOfflineEntry } from '../servic
*/
export class AddonModGlossaryEntriesSource extends CoreRoutedItemsManagerSource<AddonModGlossaryEntryItem> {
static readonly NEW_ENTRY: AddonModGlossaryNewEntryForm = { newEntry: true };
readonly COURSE_ID: number;
readonly CM_ID: number;
readonly GLOSSARY_PATH_PREFIX: string;
@ -54,16 +52,6 @@ export class AddonModGlossaryEntriesSource extends CoreRoutedItemsManagerSource<
this.GLOSSARY_PATH_PREFIX = glossaryPathPrefix;
}
/**
* Type guard to infer NewEntryForm objects.
*
* @param entry Item to check.
* @returns Whether the item is a new entry form.
*/
isNewEntryForm(entry: AddonModGlossaryEntryItem): entry is AddonModGlossaryNewEntryForm {
return 'newEntry' in entry;
}
/**
* Type guard to infer entry objects.
*
@ -81,22 +69,18 @@ export class AddonModGlossaryEntriesSource extends CoreRoutedItemsManagerSource<
* @returns Whether the item is an offline entry.
*/
isOfflineEntry(entry: AddonModGlossaryEntryItem): entry is AddonModGlossaryOfflineEntry {
return !this.isNewEntryForm(entry) && !this.isOnlineEntry(entry);
return !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}entry/new-${entry.timecreated}`;
}
return `${this.GLOSSARY_PATH_PREFIX}edit/0`;
return `${this.GLOSSARY_PATH_PREFIX}entry/${entry.id}`;
}
/**
@ -263,7 +247,6 @@ export class AddonModGlossaryEntriesSource extends CoreRoutedItemsManagerSource<
offlineEntries.sort((a, b) => a.concept.localeCompare(b.concept));
entries.push(AddonModGlossaryEntriesSource.NEW_ENTRY);
entries.push(...offlineEntries);
}
@ -315,12 +298,7 @@ export class AddonModGlossaryEntriesSource extends CoreRoutedItemsManagerSource<
/**
* 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 };
export type AddonModGlossaryEntryItem = AddonModGlossaryEntry | AddonModGlossaryOfflineEntry;
/**
* Fetch mode to sort entries.

View File

@ -1,31 +0,0 @@
// (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 { CoreSwipeNavigationItemsManager } from '@classes/items-management/swipe-navigation-items-manager';
import { AddonModGlossaryEntriesSource, AddonModGlossaryEntryItem } from './glossary-entries-source';
/**
* Helper to manage swiping within a collection of glossary entries.
*/
export abstract class AddonModGlossaryEntriesSwipeManager
extends CoreSwipeNavigationItemsManager<AddonModGlossaryEntryItem, AddonModGlossaryEntriesSource> {
/**
* @inheritdoc
*/
protected skipItemInSwipe(item: AddonModGlossaryEntryItem): boolean {
return this.getSource().isNewEntryForm(item);
}
}

View File

@ -31,7 +31,7 @@
[componentId]="componentId" [courseId]="courseId" [hasDataToSync]="hasOffline" (completionChanged)="onCompletionChange()">
</core-course-module-info>
<ion-list *ngIf="!isSearch && entries && entries.offlineEntries.length > 0">
<ion-list *ngIf="!isSearch && entries && entries.offlineEntries.length > 0" class="addon-mod-glossary-index--offline-entries">
<ion-item-divider>
<ion-label>
<h2 class="big">{{ 'addon.mod_glossary.entriestobesynced' | translate }}</h2>
@ -40,9 +40,12 @@
<ion-item *ngFor="let entry of entries.offlineEntries" (click)="entries.select(entry)" detail="false" button
[attr.aria-current]="entries.getItemAriaCurrent(entry)">
<ion-label>
<core-format-text [text]="entry.concept" contextLevel="module" [contextInstanceId]="glossary!.coursemodule"
[courseId]="courseId">
</core-format-text>
<div class="addon-mod-glossary-index--offline-entry">
<core-format-text [text]="entry.concept" contextLevel="module" [contextInstanceId]="glossary!.coursemodule"
[courseId]="courseId">
</core-format-text>
<ion-icon name="fas-rotate" class="ion-margin-start" aria-hidden="true"></ion-icon>
</div>
</ion-label>
</ion-item>
</ion-list>

View File

@ -0,0 +1,13 @@
:host {
.addon-mod-glossary-index--offline-entries {
border-bottom: 1px solid var(--stroke);
}
.addon-mod-glossary-index--offline-entry {
display: flex;
justify-content: flex-start;
align-items: center;
}
}

View File

@ -26,6 +26,7 @@ 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';
@ -61,6 +62,7 @@ import { AddonModGlossaryModePickerPopoverComponent } from '../mode-picker/mode-
@Component({
selector: 'addon-mod-glossary-index',
templateUrl: 'addon-mod-glossary-index.html',
styleUrls: ['index.scss'],
})
export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivityComponent
implements OnInit, AfterViewInit, OnDestroy {
@ -399,7 +401,11 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
* Opens new entry editor.
*/
openNewEntry(): void {
this.entries?.select(AddonModGlossaryEntriesSource.NEW_ENTRY);
CoreNavigator.navigate(
this.splitView.outletActivated
? '../new'
: './entry/new',
);
}
/**

View File

@ -27,13 +27,9 @@ const mobileRoutes: Routes = [
component: AddonModGlossaryIndexPage,
},
{
path: ':courseId/:cmId/entry/:entryId',
path: ':courseId/:cmId/entry/:entrySlug',
loadChildren: () => import('./glossary-entry-lazy.module').then(m => m.AddonModGlossaryEntryLazyModule),
},
{
path: ':courseId/:cmId/edit/:timecreated',
loadChildren: () => import('./glossary-edit-lazy.module').then(m => m.AddonModGlossaryEditLazyModule),
},
];
const tabletRoutes: Routes = [
@ -42,18 +38,18 @@ const tabletRoutes: Routes = [
component: AddonModGlossaryIndexPage,
children: [
{
path: 'entry/:entryId',
path: 'entry/:entrySlug',
loadChildren: () => import('./glossary-entry-lazy.module').then(m => m.AddonModGlossaryEntryLazyModule),
},
{
path: 'edit/:timecreated',
loadChildren: () => import('./glossary-edit-lazy.module').then(m => m.AddonModGlossaryEditLazyModule),
},
],
},
];
const routes: Routes = [
{
path: ':courseId/:cmId/entry/new',
loadChildren: () => import('./glossary-edit-lazy.module').then(m => m.AddonModGlossaryEditLazyModule),
},
...conditionalRoutes(mobileRoutes, () => CoreScreen.isMobile),
...conditionalRoutes(tabletRoutes, () => CoreScreen.isTablet),
];

View File

@ -49,50 +49,35 @@ export const ADDON_MOD_GLOSSARY_SERVICES: Type<unknown>[] = [
];
const mainMenuRoutes: Routes = [
{
path: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/entry/:entryId`,
loadChildren: () => import('./glossary-entry-lazy.module').then(m => m.AddonModGlossaryEntryLazyModule),
data: { swipeEnabled: false },
},
{
path: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/edit/:timecreated`,
loadChildren: () => import('./glossary-edit-lazy.module').then(m => m.AddonModGlossaryEditLazyModule),
data: { swipeEnabled: false },
},
// Course activity navigation.
{
path: AddonModGlossaryModuleHandlerService.PAGE_NAME,
loadChildren: () => import('./glossary-lazy.module').then(m => m.AddonModGlossaryLazyModule),
},
// Single Activity format navigation.
{
path: `${COURSE_CONTENTS_PATH}/${AddonModGlossaryModuleHandlerService.PAGE_NAME}/entry/new`,
loadChildren: () => import('./glossary-edit-lazy.module').then(m => m.AddonModGlossaryEditLazyModule),
data: { glossaryPathPrefix: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` },
},
...conditionalRoutes(
[
{
path: `${COURSE_CONTENTS_PATH}/${AddonModGlossaryModuleHandlerService.PAGE_NAME}/entry/:entryId`,
loadChildren: () => import('./glossary-entry-lazy.module').then(m => m.AddonModGlossaryEntryLazyModule),
data: { glossaryPathPrefix: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` },
},
{
path: `${COURSE_CONTENTS_PATH}/${AddonModGlossaryModuleHandlerService.PAGE_NAME}/edit/:timecreated`,
loadChildren: () => import('./glossary-edit-lazy.module').then(m => m.AddonModGlossaryEditLazyModule),
data: { glossaryPathPrefix: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` },
},
],
[{
path: `${COURSE_CONTENTS_PATH}/${AddonModGlossaryModuleHandlerService.PAGE_NAME}/entry/:entrySlug`,
loadChildren: () => import('./glossary-entry-lazy.module').then(m => m.AddonModGlossaryEntryLazyModule),
data: { glossaryPathPrefix: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` },
}],
() => CoreScreen.isMobile,
),
];
// Single Activity format navigation.
const courseContentsRoutes: Routes = conditionalRoutes(
[
{
path: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/entry/:entryId`,
loadChildren: () => import('./glossary-entry-lazy.module').then(m => m.AddonModGlossaryEntryLazyModule),
data: { glossaryPathPrefix: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` },
},
{
path: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/edit/:timecreated`,
loadChildren: () => import('./glossary-edit-lazy.module').then(m => m.AddonModGlossaryEditLazyModule),
data: { glossaryPathPrefix: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` },
},
],
[{
path: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/entry/:entrySlug`,
loadChildren: () => import('./glossary-entry-lazy.module').then(m => m.AddonModGlossaryEntryLazyModule),
data: { glossaryPathPrefix: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` },
}],
() => CoreScreen.isTablet,
);

View File

@ -17,6 +17,7 @@
"definition": "Definition",
"deleteentry": "Delete entry",
"entriestobesynced": "Entries to be synced",
"entry": "Entry",
"entrydeleted": "Entry deleted",
"entrypendingapproval": "This entry is pending approval.",
"entryusedynalink": "This entry should be automatically linked",

View File

@ -11,7 +11,7 @@
</ion-title>
</ion-toolbar>
</ion-header>
<ion-content [core-swipe-navigation]="entries">
<ion-content>
<core-loading [hideUntil]="loaded">
<form #editFormEl *ngIf="glossary">
<ion-item>

View File

@ -12,11 +12,10 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnInit, ViewChild, ElementRef, Optional, OnDestroy } from '@angular/core';
import { Component, OnInit, ViewChild, ElementRef, Optional } from '@angular/core';
import { FormControl } from '@angular/forms';
import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router';
import { ActivatedRoute } from '@angular/router';
import { CoreError } from '@classes/errors/error';
import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-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';
@ -29,8 +28,6 @@ import { CoreUtils } from '@services/utils/utils';
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,
@ -48,7 +45,7 @@ import { AddonModGlossaryOffline } from '../../services/glossary-offline';
selector: 'page-addon-mod-glossary-edit',
templateUrl: 'edit.html',
})
export class AddonModGlossaryEditPage implements OnInit, OnDestroy, CanLeave {
export class AddonModGlossaryEditPage implements OnInit, CanLeave {
@ViewChild('editFormEl') formElement?: ElementRef;
@ -74,7 +71,6 @@ export class AddonModGlossaryEditPage implements OnInit, OnDestroy, CanLeave {
};
originalData?: AddonModGlossaryFormData;
entries?: AddonModGlossaryEditEntriesSwipeManager;
protected syncId?: string;
protected syncObserver?: CoreEventObserver;
@ -88,28 +84,10 @@ export class AddonModGlossaryEditPage implements OnInit, OnDestroy, CanLeave {
*/
async ngOnInit(): Promise<void> {
try {
const routeData = this.route.snapshot.data;
const timecreated = CoreNavigator.getRequiredRouteNumberParam('timecreated');
this.cmId = CoreNavigator.getRequiredRouteNumberParam('cmId');
this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId');
this.editorExtraParams.timecreated = timecreated;
this.handler = new AddonModGlossaryOfflineFormHandler(
this,
timecreated,
CoreNavigator.getRouteParam<string>('concept'),
);
if (timecreated !== 0 && (routeData.swipeEnabled ?? true)) {
const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource(
AddonModGlossaryEntriesSource,
[this.courseId, this.cmId, routeData.glossaryPathPrefix ?? ''],
);
this.entries = new AddonModGlossaryEditEntriesSwipeManager(source);
await this.entries.start();
}
this.handler = new AddonModGlossaryNewFormHandler(this);
} catch (error) {
CoreDomUtils.showErrorModal(error);
@ -121,13 +99,6 @@ export class AddonModGlossaryEditPage implements OnInit, OnDestroy, CanLeave {
this.fetchData();
}
/**
* @inheritdoc
*/
ngOnDestroy(): void {
this.entries?.destroy();
}
/**
* Fetch required data.
*
@ -287,132 +258,131 @@ abstract class AddonModGlossaryFormHandler {
abstract save(glossary: AddonModGlossaryGlossary): Promise<boolean>;
/**
* Upload entry attachments if any.
* Upload attachments online.
*
* @param timecreated Time when the entry was created.
* @param glossary Glossary.
* @returns Attachements result.
* @returns Uploaded attachments item id.
*/
protected async uploadAttachments(timecreated: number, glossary: AddonModGlossaryGlossary): Promise<{
saveOffline: boolean;
attachmentsResult?: number | CoreFileUploaderStoreFilesResult;
}> {
protected async uploadAttachments(glossary: AddonModGlossaryGlossary): Promise<number> {
const data = this.page.data;
const itemId = await CoreFileUploader.uploadOrReuploadFiles(
data.attachments,
AddonModGlossaryProvider.COMPONENT,
glossary.id,
);
if (!data.attachments.length) {
return {
saveOffline: false,
};
}
try {
const attachmentsResult = await CoreFileUploader.uploadOrReuploadFiles(
data.attachments,
AddonModGlossaryProvider.COMPONENT,
glossary.id,
);
return {
saveOffline: false,
attachmentsResult,
};
} catch (error) {
if (CoreUtils.isWebServiceError(error)) {
throw error;
}
// Cannot upload them in online, save them in offline.
const attachmentsResult = await AddonModGlossaryHelper.storeFiles(
glossary.id,
data.concept,
timecreated,
data.attachments,
);
return {
saveOffline: true,
attachmentsResult,
};
}
}
}
/**
* Helper to manage offline form data.
*/
class AddonModGlossaryOfflineFormHandler extends AddonModGlossaryFormHandler {
private timecreated: number;
private concept: string;
constructor(page: AddonModGlossaryEditPage, timecreated: number, concept: string | undefined) {
super(page);
this.timecreated = timecreated;
this.concept = concept ?? '';
return itemId;
}
/**
* @inheritdoc
* Store attachments offline.
*
* @param glossary Glossary.
* @param timecreated Entry time created.
* @returns Storage result.
*/
async loadData(glossary: AddonModGlossaryGlossary): Promise<void> {
if (this.timecreated === 0) {
return;
}
protected async storeAttachments(
glossary: AddonModGlossaryGlossary,
timecreated: number,
): Promise<CoreFileUploaderStoreFilesResult> {
const data = this.page.data;
const entry = await AddonModGlossaryOffline.getOfflineEntry(glossary.id, this.concept, this.timecreated);
const result = await AddonModGlossaryHelper.storeFiles(
glossary.id,
data.concept,
timecreated,
data.attachments,
);
data.concept = entry.concept || '';
data.definition = entry.definition || '';
data.timecreated = entry.timecreated;
this.page.originalData = {
concept: data.concept,
definition: data.definition,
attachments: data.attachments.slice(),
timecreated: data.timecreated,
categories: data.categories.slice(),
aliases: data.aliases,
usedynalink: data.usedynalink,
casesensitive: data.casesensitive,
fullmatch: data.fullmatch,
};
if (entry.options) {
data.categories = (entry.options.categories && (<string> entry.options.categories).split(',')) || [];
data.aliases = <string> entry.options.aliases || '';
data.usedynalink = !!entry.options.usedynalink;
if (data.usedynalink) {
data.casesensitive = !!entry.options.casesensitive;
data.fullmatch = !!entry.options.fullmatch;
}
}
// Treat offline attachments if any.
if (entry.attachments?.offline) {
data.attachments = await AddonModGlossaryHelper.getStoredFiles(glossary.id, entry.concept, entry.timecreated);
this.page.originalData.attachments = data.attachments.slice();
}
this.page.definitionControl.setValue(data.definition);
return result;
}
/**
* @inheritdoc
* Create an offline entry.
*
* @param glossary Glossary.
* @param timecreated Time created.
* @param uploadedAttachments Uploaded attachments.
*/
async save(glossary: AddonModGlossaryGlossary): Promise<boolean> {
let entryId: number | false = false;
protected async createOfflineEntry(
glossary: AddonModGlossaryGlossary,
timecreated: number,
uploadedAttachments?: CoreFileUploaderStoreFilesResult,
): Promise<void> {
const data = this.page.data;
const timecreated = this.timecreated || Date.now();
const options = this.getSaveOptions(glossary);
const definition = CoreTextUtils.formatHtmlLines(data.definition);
// Upload attachments first if any.
const { saveOffline, attachmentsResult } = await this.uploadAttachments(timecreated, glossary);
if (!glossary.allowduplicatedentries) {
// Check if the entry is duplicated in online or offline mode.
const isUsed = await AddonModGlossary.isConceptUsed(glossary.id, data.concept, {
timeCreated: data.timecreated,
cmId: this.page.cmId,
});
if (isUsed) {
// There's a entry with same name, reject with error message.
throw new CoreError(Translate.instant('addon.mod_glossary.errconceptalreadyexists'));
}
}
await AddonModGlossaryOffline.addOfflineEntry(
glossary.id,
data.concept,
definition,
this.page.courseId,
options,
uploadedAttachments,
timecreated,
undefined,
undefined,
data,
);
}
/**
* Create an online entry.
*
* @param glossary Glossary.
* @param timecreated Time created.
* @param uploadedAttachmentsId Id of the uploaded attachments.
* @param allowOffline Allow falling back to creating the entry offline.
* @returns Entry id.
*/
protected async createOnlineEntry(
glossary: AddonModGlossaryGlossary,
timecreated: number,
uploadedAttachmentsId?: number,
allowOffline?: boolean,
): Promise<number | false> {
const data = this.page.data;
const options = this.getSaveOptions(glossary);
const definition = CoreTextUtils.formatHtmlLines(data.definition);
const entryId = await AddonModGlossary.addEntry(
glossary.id,
data.concept,
definition,
this.page.courseId,
options,
uploadedAttachmentsId,
{
timeCreated: timecreated,
discardEntry: data,
allowOffline: allowOffline,
checkDuplicates: !glossary.allowduplicatedentries,
},
);
return entryId;
}
/**
* Get additional options to save an entry.
*
* @param glossary Glossary.
* @returns Options.
*/
protected getSaveOptions(glossary: AddonModGlossaryGlossary): Record<string, AddonModGlossaryEntryOption> {
const data = this.page.data;
const options: Record<string, AddonModGlossaryEntryOption> = {
aliases: data.aliases,
categories: data.categories.join(','),
@ -420,58 +390,58 @@ class AddonModGlossaryOfflineFormHandler extends AddonModGlossaryFormHandler {
if (glossary.usedynalink) {
options.usedynalink = data.usedynalink ? 1 : 0;
if (data.usedynalink) {
options.casesensitive = data.casesensitive ? 1 : 0;
options.fullmatch = data.fullmatch ? 1 : 0;
}
}
if (saveOffline) {
if (!glossary.allowduplicatedentries) {
// Check if the entry is duplicated in online or offline mode.
const isUsed = await AddonModGlossary.isConceptUsed(glossary.id, data.concept, {
timeCreated: data.timecreated,
cmId: this.page.cmId,
});
return options;
}
if (isUsed) {
// There's a entry with same name, reject with error message.
throw new CoreError(Translate.instant('addon.mod_glossary.errconceptalreadyexists'));
}
/**
* Helper to manage the form data for creating a new entry.
*/
class AddonModGlossaryNewFormHandler extends AddonModGlossaryFormHandler {
/**
* @inheritdoc
*/
async loadData(): Promise<void> {
// There is no data to load, given that this is a new entry.
}
/**
* @inheritdoc
*/
async save(glossary: AddonModGlossaryGlossary): Promise<boolean> {
const data = this.page.data;
const timecreated = Date.now();
// Upload attachments first if any.
let onlineAttachments: number | undefined = undefined;
let offlineAttachments: CoreFileUploaderStoreFilesResult | undefined = undefined;
if (data.attachments.length) {
try {
onlineAttachments = await this.uploadAttachments(glossary);
} catch (error) {
if (CoreUtils.isWebServiceError(error)) {
throw error;
}
}
// Save entry in offline.
await AddonModGlossaryOffline.addOfflineEntry(
glossary.id,
data.concept,
definition,
this.page.courseId,
options,
<CoreFileUploaderStoreFilesResult> attachmentsResult,
timecreated,
undefined,
undefined,
data,
);
} else {
// Try to send it to server.
// Don't allow offline if there are attachments since they were uploaded fine.
entryId = await AddonModGlossary.addEntry(
glossary.id,
data.concept,
definition,
this.page.courseId,
options,
attachmentsResult,
{
timeCreated: timecreated,
discardEntry: data,
allowOffline: !data.attachments.length,
checkDuplicates: !glossary.allowduplicatedentries,
},
);
offlineAttachments = await this.storeAttachments(glossary, timecreated);
}
}
// Save entry data.
const entryId = offlineAttachments
? await this.createOfflineEntry(glossary, timecreated, offlineAttachments)
: await this.createOnlineEntry(glossary, timecreated, onlineAttachments, !data.attachments.length);
// Delete the local files from the tmp folder.
CoreFileUploader.clearTmpFiles(data.attachments);
@ -491,20 +461,6 @@ class AddonModGlossaryOfflineFormHandler extends AddonModGlossaryFormHandler {
}
/**
* 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}`;
}
}
/**
* Form data.
*/

View File

@ -18,6 +18,12 @@
<core-loading [hideUntil]="loaded">
<ng-container *ngIf="entry && loaded">
<ion-card *ngIf="offlineEntry" class="core-warning-card">
<ion-item>
<ion-icon name="fas-triangle-exclamation" slot="start" aria-hidden="true"></ion-icon>
<ion-label>{{ 'core.hasdatatosync' | translate: { $a: 'addon.mod_glossary.entry' | translate } }}</ion-label>
</ion-item>
</ion-card>
<ion-item class="ion-text-wrap" *ngIf="showAuthor">
<core-user-avatar [user]="entry" slot="start"></core-user-avatar>
<ion-label>
@ -26,9 +32,9 @@
[courseId]="courseId">
</core-format-text>
</h2>
<p>{{ entry.userfullname }}</p>
<p *ngIf="onlineEntry">{{ onlineEntry.userfullname }}</p>
</ion-label>
<ion-note slot="end" *ngIf="showDate">{{ entry.timemodified | coreDateDayOrTime }}</ion-note>
<ion-note slot="end" *ngIf="showDate && onlineEntry">{{ onlineEntry.timemodified | coreDateDayOrTime }}</ion-note>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="!showAuthor">
<ion-label>
@ -37,7 +43,7 @@
</core-format-text>
</p>
</ion-label>
<ion-note slot="end" *ngIf="showDate">{{ entry.timemodified | coreDateDayOrTime }}</ion-note>
<ion-note slot="end" *ngIf="showDate && onlineEntry">{{ onlineEntry.timemodified | coreDateDayOrTime }}</ion-note>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
@ -53,32 +59,37 @@
</ion-button>
</div>
</ion-item>
<div *ngIf="entry.attachment">
<core-file *ngFor="let file of entry.attachments" [file]="file" [component]="component" [componentId]="componentId">
<div *ngIf="onlineEntry && onlineEntry.attachment">
<core-file *ngFor="let file of onlineEntry.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">
<div *ngIf="offlineEntry && offlineEntry.attachments">
<core-file *ngFor="let file of offlineEntry.attachments.online" [file]="file" [component]="component"
[componentId]="componentId">
</core-file>
</div>
<ion-item class="ion-text-wrap" *ngIf="onlineEntry && tagsEnabled && entry && onlineEntry.tags && onlineEntry.tags.length > 0">
<ion-label>
<div slot="start">{{ 'core.tag.tags' | translate }}:</div>
<core-tag-list [tags]="entry.tags"></core-tag-list>
<core-tag-list [tags]="onlineEntry.tags"></core-tag-list>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="!entry.approved">
<ion-item class="ion-text-wrap" *ngIf="onlineEntry && !onlineEntry.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 *ngIf="glossary && glossary.allowcomments && onlineEntry && onlineEntry.id > 0 && commentsEnabled"
contextLevel="module" [instanceId]="glossary.coursemodule" component="mod_glossary" [itemId]="onlineEntry.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"
<core-rating-rate *ngIf="glossary && ratingInfo && onlineEntry" [ratingInfo]="ratingInfo" contextLevel="module"
[instanceId]="glossary.coursemodule" [itemId]="onlineEntry.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 *ngIf="glossary && ratingInfo && onlineEntry" [ratingInfo]="ratingInfo" contextLevel="module"
[instanceId]="glossary.coursemodule" [itemId]="onlineEntry.id" [courseId]="glossary.course"
[aggregateMethod]="glossary.assessed" [scaleId]="glossary.scale">
</core-rating-aggregate>
</ng-container>

View File

@ -12,9 +12,11 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { AddonModGlossaryOffline, AddonModGlossaryOfflineEntry } from '@addons/mod/glossary/services/glossary-offline';
import { Component, OnDestroy, OnInit, Optional, ViewChild } from '@angular/core';
import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router';
import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker';
import { CoreSwipeNavigationItemsManager } from '@classes/items-management/swipe-navigation-items-manager';
import { CoreSplitViewComponent } from '@components/split-view/split-view';
import { CoreCommentsCommentsComponent } from '@features/comments/components/comments/comments';
import { CoreComments } from '@features/comments/services/comments';
@ -26,8 +28,7 @@ import { CoreNetwork } from '@services/network';
import { CoreDomUtils, ToastDuration } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { Translate } from '@singletons';
import { AddonModGlossaryEntriesSource } from '../../classes/glossary-entries-source';
import { AddonModGlossaryEntriesSwipeManager } from '../../classes/glossary-entries-swipe-manager';
import { AddonModGlossaryEntriesSource, AddonModGlossaryEntryItem } from '../../classes/glossary-entries-source';
import {
AddonModGlossary,
AddonModGlossaryEntry,
@ -48,8 +49,9 @@ export class AddonModGlossaryEntryPage implements OnInit, OnDestroy {
component = AddonModGlossaryProvider.COMPONENT;
componentId?: number;
entry?: AddonModGlossaryEntry;
entries?: AddonModGlossaryEntryEntriesSwipeManager;
onlineEntry?: AddonModGlossaryEntry;
offlineEntry?: AddonModGlossaryOfflineEntry;
entries!: AddonModGlossaryEntryEntriesSwipeManager;
glossary?: AddonModGlossaryGlossary;
loaded = false;
showAuthor = false;
@ -59,52 +61,67 @@ export class AddonModGlossaryEntryPage implements OnInit, OnDestroy {
canDelete = false;
commentsEnabled = false;
courseId!: number;
cmId?: number;
protected entryId!: number;
cmId!: number;
constructor(@Optional() protected splitView: CoreSplitViewComponent, protected route: ActivatedRoute) {}
get entry(): AddonModGlossaryEntry | AddonModGlossaryOfflineEntry | undefined {
return this.onlineEntry ?? this.offlineEntry;
}
/**
* @inheritdoc
*/
async ngOnInit(): Promise<void> {
let onlineEntryId: number | null = null;
let offlineEntry: {
concept: string;
timecreated: number;
} | null = null;
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();
this.cmId = CoreNavigator.getRequiredRouteNumberParam('cmId');
if (routeData.swipeEnabled ?? true) {
this.cmId = CoreNavigator.getRequiredRouteNumberParam('cmId');
const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource(
AddonModGlossaryEntriesSource,
[this.courseId, this.cmId, routeData.glossaryPathPrefix ?? ''],
);
const entrySlug = CoreNavigator.getRequiredRouteParam<string>('entrySlug');
const routeData = this.route.snapshot.data;
const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource(
AddonModGlossaryEntriesSource,
[this.courseId, this.cmId, routeData.glossaryPathPrefix ?? ''],
);
this.entries = new AddonModGlossaryEntryEntriesSwipeManager(source);
this.entries = new AddonModGlossaryEntryEntriesSwipeManager(source);
await this.entries.start();
await this.entries.start();
if (entrySlug.startsWith('new-')) {
offlineEntry = {
concept : CoreNavigator.getRequiredRouteParam<string>('concept'),
timecreated: Number(entrySlug.slice(4)),
};
} else {
this.cmId = CoreNavigator.getRouteNumberParam('cmId');
onlineEntryId = Number(entrySlug);
}
} catch (error) {
CoreDomUtils.showErrorModal(error);
CoreNavigator.back();
return;
}
try {
await this.fetchEntry();
if (onlineEntryId) {
await this.loadOnlineEntry(onlineEntryId);
if (!this.glossary || !this.componentId) {
return;
if (!this.glossary || !this.componentId) {
return;
}
await CoreUtils.ignoreErrors(AddonModGlossary.logEntryView(onlineEntryId, this.componentId, this.glossary?.name));
} else if (offlineEntry) {
await this.loadOfflineEntry(offlineEntry.concept, offlineEntry.timecreated);
}
await CoreUtils.ignoreErrors(AddonModGlossary.logEntryView(this.entryId, this.componentId, this.glossary.name));
} finally {
this.loaded = true;
}
@ -114,14 +131,18 @@ export class AddonModGlossaryEntryPage implements OnInit, OnDestroy {
* @inheritdoc
*/
ngOnDestroy(): void {
this.entries?.destroy();
this.entries.destroy();
}
/**
* Delete entry.
*/
async deleteEntry(): Promise<void> {
const entryId = this.entry?.id;
if (!this.onlineEntry) {
return;
}
const entryId = this.onlineEntry.id;
const glossaryId = this.glossary?.id;
const cancelled = await CoreUtils.promiseFails(
CoreDomUtils.showConfirm(Translate.instant('addon.mod_glossary.areyousuredelete')),
@ -141,7 +162,7 @@ export class AddonModGlossaryEntryPage implements OnInit, OnDestroy {
await CoreUtils.ignoreErrors(AddonModGlossary.invalidateEntriesByCategory(glossaryId));
await CoreUtils.ignoreErrors(AddonModGlossary.invalidateEntriesByDate(glossaryId, 'CREATION'));
await CoreUtils.ignoreErrors(AddonModGlossary.invalidateEntriesByDate(glossaryId, 'UPDATE'));
await CoreUtils.ignoreErrors(this.entries?.getSource().invalidateCache(false));
await CoreUtils.ignoreErrors(this.entries.getSource().invalidateCache(false));
CoreDomUtils.showToast('addon.mod_glossary.entrydeleted', true, ToastDuration.LONG);
@ -164,67 +185,100 @@ export class AddonModGlossaryEntryPage implements OnInit, OnDestroy {
* @returns Promise resolved when done.
*/
async doRefresh(refresher?: IonRefresher): Promise<void> {
if (this.glossary?.allowcomments && this.entry && this.entry.id > 0 && this.commentsEnabled && this.comments) {
// Refresh comments. Don't add it to promises because we don't want the comments fetch to block the entry fetch.
if (this.onlineEntry && this.glossary?.allowcomments && this.onlineEntry.id > 0 && this.commentsEnabled && this.comments) {
// Refresh comments asynchronously (without blocking the current promise).
CoreUtils.ignoreErrors(this.comments.doRefresh());
}
try {
await CoreUtils.ignoreErrors(AddonModGlossary.invalidateEntry(this.entryId));
if (this.onlineEntry) {
await CoreUtils.ignoreErrors(AddonModGlossary.invalidateEntry(this.onlineEntry.id));
await this.loadOnlineEntry(this.onlineEntry.id);
} else if (this.offlineEntry) {
const entrySlug = CoreNavigator.getRequiredRouteParam<string>('entrySlug');
const timecreated = Number(entrySlug.slice(4));
await this.fetchEntry();
await this.loadOfflineEntry(timecreated);
}
} finally {
refresher?.complete();
}
}
/**
* Convenience function to get the glossary entry.
*
* @returns Promise resolved when done.
* Load online entry data.
*/
protected async fetchEntry(): Promise<void> {
protected async loadOnlineEntry(entryId: number): Promise<void> {
try {
const result = await AddonModGlossary.getEntry(this.entryId);
const result = await AddonModGlossary.getEntry(entryId);
const canDeleteEntries = CoreNetwork.isOnline() && await AddonModGlossary.canDeleteEntries();
this.entry = result.entry;
this.onlineEntry = result.entry;
this.ratingInfo = result.ratinginfo;
this.canDelete = canDeleteEntries && !!result.permissions?.candelete;
if (this.glossary) {
// Glossary already loaded, nothing else to load.
return;
}
// Load the glossary.
this.glossary = await AddonModGlossary.getGlossaryById(this.courseId, this.entry.glossaryid);
this.componentId = this.glossary.coursemodule;
switch (this.glossary.displayformat) {
case 'fullwithauthor':
case 'encyclopedia':
this.showAuthor = true;
this.showDate = true;
break;
case 'fullwithoutauthor':
this.showAuthor = false;
this.showDate = true;
break;
default: // Default, and faq, simple, entrylist, continuous.
this.showAuthor = false;
this.showDate = false;
}
await this.loadGlossary();
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'addon.mod_glossary.errorloadingentry', true);
}
}
/**
* Load offline entry data.
*
* @param concept Entry concept.
* @param timecreated Entry Timecreated.
*/
protected async loadOfflineEntry(concept: string, timecreated: number): Promise<void> {
try {
const glossary = await this.loadGlossary();
this.offlineEntry = await AddonModGlossaryOffline.getOfflineEntry(glossary.id, concept, timecreated);
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'addon.mod_glossary.errorloadingentry', true);
}
}
/**
* Load glossary data.
*
* @returns Glossary.
*/
protected async loadGlossary(): Promise<AddonModGlossaryGlossary> {
if (this.glossary) {
return this.glossary;
}
this.glossary = await AddonModGlossary.getGlossary(this.courseId, this.cmId);
this.componentId = this.glossary.coursemodule;
switch (this.glossary.displayformat) {
case 'fullwithauthor':
case 'encyclopedia':
this.showAuthor = true;
this.showDate = true;
break;
case 'fullwithoutauthor':
this.showAuthor = false;
this.showDate = true;
break;
default: // Default, and faq, simple, entrylist, continuous.
this.showAuthor = false;
this.showDate = false;
}
return this.glossary;
}
/**
* Function called when rating is updated online.
*/
ratingUpdated(): void {
AddonModGlossary.invalidateEntry(this.entryId);
if (!this.onlineEntry) {
return;
}
AddonModGlossary.invalidateEntry(this.onlineEntry.id);
}
}
@ -232,13 +286,14 @@ export class AddonModGlossaryEntryPage implements OnInit, OnDestroy {
/**
* Helper to manage swiping within a collection of glossary entries.
*/
class AddonModGlossaryEntryEntriesSwipeManager extends AddonModGlossaryEntriesSwipeManager {
class AddonModGlossaryEntryEntriesSwipeManager
extends CoreSwipeNavigationItemsManager<AddonModGlossaryEntryItem, AddonModGlossaryEntriesSource> {
/**
* @inheritdoc
*/
protected getSelectedItemPathFromRoute(route: ActivatedRouteSnapshot): string | null {
return `${this.getSource().GLOSSARY_PATH_PREFIX}entry/${route.params.entryId}`;
return `${this.getSource().GLOSSARY_PATH_PREFIX}entry/${route.params.entrySlug}`;
}
}

View File

@ -51,14 +51,8 @@ export class AddonModGlossaryEditLinkHandlerService extends CoreContentLinksHand
);
await CoreNavigator.navigateToSitePath(
AddonModGlossaryModuleHandlerService.PAGE_NAME + '/edit/0',
{
params: {
courseId: module.course,
cmId: module.id,
},
siteId,
},
AddonModGlossaryModuleHandlerService.PAGE_NAME + `/${module.course}/${module.id}/entry/new`,
{ siteId },
);
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'addon.mod_glossary.errorloadingglossary', true);

View File

@ -56,14 +56,8 @@ export class AddonModGlossaryEntryLinkHandlerService extends CoreContentLinksHan
);
await CoreNavigator.navigateToSitePath(
AddonModGlossaryModuleHandlerService.PAGE_NAME + `/entry/${entryId}`,
{
params: {
courseId: module.course,
cmId: module.id,
},
siteId,
},
AddonModGlossaryModuleHandlerService.PAGE_NAME + `/${module.course}/${module.id}/entry/${entryId}`,
{ siteId },
);
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'addon.mod_glossary.errorloadingentry', true);

View File

@ -280,6 +280,7 @@ Feature: Test glossary navigation
| Concept | Tomato |
| Definition | Tomato is a fruit |
And I press "Save" in the app
And I press "Add a new entry" in the app
And I set the following fields to these values in the app:
| Concept | Cashew |
| Definition | Cashew is a fruit |