MOBILE-2652 glossary: Edit offline entries

main
Noel De Martin 2023-03-29 14:02:26 +02:00
parent 957dece787
commit 3c443a26c4
14 changed files with 343 additions and 148 deletions

View File

@ -696,6 +696,7 @@
"addon.mod_glossary.concept": "glossary", "addon.mod_glossary.concept": "glossary",
"addon.mod_glossary.definition": "glossary", "addon.mod_glossary.definition": "glossary",
"addon.mod_glossary.deleteentry": "glossary", "addon.mod_glossary.deleteentry": "glossary",
"addon.mod_glossary.editentry": "glossary",
"addon.mod_glossary.entriestobesynced": "local_moodlemobileapp", "addon.mod_glossary.entriestobesynced": "local_moodlemobileapp",
"addon.mod_glossary.entry": "glossary", "addon.mod_glossary.entry": "glossary",
"addon.mod_glossary.entrydeleted": "glossary", "addon.mod_glossary.entrydeleted": "glossary",

View File

@ -86,17 +86,11 @@ export class AddonModGlossaryEntriesSource extends CoreRoutedItemsManagerSource<
/** /**
* @inheritdoc * @inheritdoc
*/ */
getItemQueryParams(entry: AddonModGlossaryEntryItem): Params { getItemQueryParams(): Params {
const params: Params = { return {
cmId: this.CM_ID, cmId: this.CM_ID,
courseId: this.COURSE_ID, courseId: this.COURSE_ID,
}; };
if (this.isOfflineEntry(entry)) {
params.concept = entry.concept;
}
return params;
} }
/** /**

View File

@ -45,6 +45,7 @@ import {
AddonModGlossaryProvider, AddonModGlossaryProvider,
GLOSSARY_ENTRY_ADDED, GLOSSARY_ENTRY_ADDED,
GLOSSARY_ENTRY_DELETED, GLOSSARY_ENTRY_DELETED,
GLOSSARY_ENTRY_UPDATED,
} from '../../services/glossary'; } from '../../services/glossary';
import { AddonModGlossaryOfflineEntry } from '../../services/glossary-offline'; import { AddonModGlossaryOfflineEntry } from '../../services/glossary-offline';
import { import {
@ -149,6 +150,13 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
this.showLoadingAndRefresh(false); this.showLoadingAndRefresh(false);
}), }),
CoreEvents.on(GLOSSARY_ENTRY_UPDATED, ({ glossaryId }) => {
if (this.glossary?.id !== glossaryId) {
return;
}
this.showLoadingAndRefresh(false);
}),
CoreEvents.on(GLOSSARY_ENTRY_DELETED, ({ glossaryId }) => { CoreEvents.on(GLOSSARY_ENTRY_DELETED, ({ glossaryId }) => {
if (this.glossary?.id !== glossaryId) { if (this.glossary?.id !== glossaryId) {
return; return;

View File

@ -50,6 +50,10 @@ const routes: Routes = [
path: ':courseId/:cmId/entry/new', path: ':courseId/:cmId/entry/new',
loadChildren: () => import('./glossary-edit-lazy.module').then(m => m.AddonModGlossaryEditLazyModule), loadChildren: () => import('./glossary-edit-lazy.module').then(m => m.AddonModGlossaryEditLazyModule),
}, },
{
path: ':courseId/:cmId/entry/:entrySlug/edit',
loadChildren: () => import('./glossary-edit-lazy.module').then(m => m.AddonModGlossaryEditLazyModule),
},
...conditionalRoutes(mobileRoutes, () => CoreScreen.isMobile), ...conditionalRoutes(mobileRoutes, () => CoreScreen.isMobile),
...conditionalRoutes(tabletRoutes, () => CoreScreen.isTablet), ...conditionalRoutes(tabletRoutes, () => CoreScreen.isTablet),
]; ];

View File

@ -61,6 +61,11 @@ const mainMenuRoutes: Routes = [
loadChildren: () => import('./glossary-edit-lazy.module').then(m => m.AddonModGlossaryEditLazyModule), loadChildren: () => import('./glossary-edit-lazy.module').then(m => m.AddonModGlossaryEditLazyModule),
data: { glossaryPathPrefix: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` }, data: { glossaryPathPrefix: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` },
}, },
{
path: `${COURSE_CONTENTS_PATH}/${AddonModGlossaryModuleHandlerService.PAGE_NAME}/entry/:entrySlug/edit`,
loadChildren: () => import('./glossary-edit-lazy.module').then(m => m.AddonModGlossaryEditLazyModule),
data: { glossaryPathPrefix: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` },
},
...conditionalRoutes( ...conditionalRoutes(
[{ [{
path: `${COURSE_CONTENTS_PATH}/${AddonModGlossaryModuleHandlerService.PAGE_NAME}/entry/:entrySlug`, path: `${COURSE_CONTENTS_PATH}/${AddonModGlossaryModuleHandlerService.PAGE_NAME}/entry/:entrySlug`,

View File

@ -16,6 +16,7 @@
"concept": "Concept", "concept": "Concept",
"definition": "Definition", "definition": "Definition",
"deleteentry": "Delete entry", "deleteentry": "Delete entry",
"editentry": "Edit entry",
"entriestobesynced": "Entries to be synced", "entriestobesynced": "Entries to be synced",
"entry": "Entry", "entry": "Entry",
"entrydeleted": "Entry deleted", "entrydeleted": "Entry deleted",

View File

@ -84,10 +84,17 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave {
*/ */
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
try { try {
const entrySlug = CoreNavigator.getRouteParam<string>('entrySlug');
this.cmId = CoreNavigator.getRequiredRouteNumberParam('cmId'); this.cmId = CoreNavigator.getRequiredRouteNumberParam('cmId');
this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId');
this.handler = new AddonModGlossaryNewFormHandler(this); if (entrySlug?.startsWith('new-')) {
const timecreated = Number(entrySlug.slice(4));
this.editorExtraParams.timecreated = timecreated;
this.handler = new AddonModGlossaryOfflineFormHandler(this, timecreated);
} else {
this.handler = new AddonModGlossaryNewFormHandler(this);
}
} catch (error) { } catch (error) {
CoreDomUtils.showErrorModal(error); CoreDomUtils.showErrorModal(error);
@ -297,82 +304,25 @@ abstract class AddonModGlossaryFormHandler {
} }
/** /**
* Create an offline entry. * Make sure that the new entry won't create any duplicates.
* *
* @param glossary Glossary. * @param glossary Glossary.
* @param timecreated Time created.
* @param uploadedAttachments Uploaded attachments.
*/ */
protected async createOfflineEntry( protected async checkDuplicates(glossary: AddonModGlossaryGlossary): Promise<void> {
glossary: AddonModGlossaryGlossary, if (glossary.allowduplicatedentries) {
timecreated: number, return;
uploadedAttachments?: CoreFileUploaderStoreFilesResult,
): Promise<void> {
const data = this.page.data;
const options = this.getSaveOptions(glossary);
const definition = CoreTextUtils.formatHtmlLines(data.definition);
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 data = this.page.data;
const options = this.getSaveOptions(glossary); const isUsed = await AddonModGlossary.isConceptUsed(glossary.id, data.concept, {
const definition = CoreTextUtils.formatHtmlLines(data.definition); timeCreated: data.timecreated,
const entryId = await AddonModGlossary.addEntry( cmId: this.page.cmId,
glossary.id, });
data.concept,
definition,
this.page.courseId,
options,
uploadedAttachmentsId,
{
timeCreated: timecreated,
discardEntry: data,
allowOffline: allowOffline,
checkDuplicates: !glossary.allowduplicatedentries,
},
);
return entryId; if (isUsed) {
// There's a entry with same name, reject with error message.
throw new CoreError(Translate.instant('addon.mod_glossary.errconceptalreadyexists'));
}
} }
/** /**
@ -402,6 +352,119 @@ abstract class AddonModGlossaryFormHandler {
} }
/**
* Helper to manage the form data for an offline entry.
*/
class AddonModGlossaryOfflineFormHandler extends AddonModGlossaryFormHandler {
private timecreated: number;
constructor(page: AddonModGlossaryEditPage, timecreated: number) {
super(page);
this.timecreated = timecreated;
}
/**
* @inheritdoc
*/
async loadData(glossary: AddonModGlossaryGlossary): Promise<void> {
const data = this.page.data;
const entry = await AddonModGlossaryOffline.getOfflineEntry(glossary.id, this.timecreated);
data.concept = entry.concept || '';
data.definition = entry.definition || '';
data.timecreated = entry.timecreated;
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 = {
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,
};
this.page.definitionControl.setValue(data.definition);
}
/**
* @inheritdoc
*/
async save(glossary: AddonModGlossaryGlossary): Promise<boolean> {
const data = this.page.data;
// Upload attachments first if any.
let offlineAttachments: CoreFileUploaderStoreFilesResult | undefined = undefined;
if (data.attachments.length) {
offlineAttachments = await this.storeAttachments(glossary, data.timecreated);
}
// Save entry data.
await this.updateOfflineEntry(glossary, offlineAttachments);
// Delete the local files from the tmp folder.
CoreFileUploader.clearTmpFiles(data.attachments);
return false;
}
/**
* Update an offline entry.
*
* @param glossary Glossary.
* @param uploadedAttachments Uploaded attachments.
*/
protected async updateOfflineEntry(
glossary: AddonModGlossaryGlossary,
uploadedAttachments?: CoreFileUploaderStoreFilesResult,
): Promise<void> {
const originalData = this.page.originalData;
const data = this.page.data;
const options = this.getSaveOptions(glossary);
const definition = CoreTextUtils.formatHtmlLines(data.definition);
if (!originalData) {
return;
}
await this.checkDuplicates(glossary);
await AddonModGlossaryOffline.updateOfflineEntry(
{
glossaryid: glossary.id,
courseid: this.page.courseId,
concept: originalData.concept,
timecreated: originalData.timecreated,
},
data.concept,
definition,
options,
uploadedAttachments,
);
}
}
/** /**
* Helper to manage the form data for creating a new entry. * Helper to manage the form data for creating a new entry.
*/ */
@ -451,14 +514,74 @@ class AddonModGlossaryNewFormHandler extends AddonModGlossaryFormHandler {
CoreEvents.trigger(CoreEvents.ACTIVITY_DATA_SENT, { module: 'glossary' }); CoreEvents.trigger(CoreEvents.ACTIVITY_DATA_SENT, { module: 'glossary' });
} }
CoreEvents.trigger(AddonModGlossaryProvider.ADD_ENTRY_EVENT, {
glossaryId: glossary.id,
entryId: entryId || undefined,
}, CoreSites.getCurrentSiteId());
return !!entryId; return !!entryId;
} }
/**
* Create an offline entry.
*
* @param glossary Glossary.
* @param timecreated Time created.
* @param uploadedAttachments Uploaded attachments.
*/
protected async createOfflineEntry(
glossary: AddonModGlossaryGlossary,
timecreated: number,
uploadedAttachments?: CoreFileUploaderStoreFilesResult,
): Promise<void> {
const data = this.page.data;
const options = this.getSaveOptions(glossary);
const definition = CoreTextUtils.formatHtmlLines(data.definition);
await this.checkDuplicates(glossary);
await AddonModGlossaryOffline.addOfflineEntry(
glossary.id,
data.concept,
definition,
this.page.courseId,
timecreated,
options,
uploadedAttachments,
undefined,
undefined,
);
}
/**
* 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,
allowOffline: allowOffline,
checkDuplicates: !glossary.allowduplicatedentries,
},
);
return entryId;
}
} }
/** /**

View File

@ -52,11 +52,16 @@
</core-format-text> </core-format-text>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item *ngIf="canDelete"> <ion-item *ngIf="canDelete || canEdit">
<div slot="end"> <div slot="end">
<ion-button fill="clear" (click)="deleteEntry()" [attr.aria-label]="'addon.mod_glossary.deleteentry' | translate"> <ion-button *ngIf="canDelete" fill="clear" (click)="deleteEntry()"
[attr.aria-label]="'addon.mod_glossary.deleteentry' | translate">
<ion-icon slot="icon-only" name="fas-trash" aria-hidden="true"></ion-icon> <ion-icon slot="icon-only" name="fas-trash" aria-hidden="true"></ion-icon>
</ion-button> </ion-button>
<ion-button *ngIf="canEdit" fill="clear" (click)="editEntry()"
[attr.aria-label]="'addon.mod_glossary.editentry' | translate">
<ion-icon slot="icon-only" name="fas-pen" aria-hidden="true"></ion-icon>
</ion-button>
</div> </div>
</ion-item> </ion-item>
<div *ngIf="onlineEntry && onlineEntry.attachment"> <div *ngIf="onlineEntry && onlineEntry.attachment">

View File

@ -29,12 +29,14 @@ import { CoreNetwork } from '@services/network';
import { CoreDomUtils, ToastDuration } from '@services/utils/dom'; import { CoreDomUtils, ToastDuration } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
import { Translate } from '@singletons'; import { Translate } from '@singletons';
import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { AddonModGlossaryEntriesSource, AddonModGlossaryEntryItem } from '../../classes/glossary-entries-source'; import { AddonModGlossaryEntriesSource, AddonModGlossaryEntryItem } from '../../classes/glossary-entries-source';
import { import {
AddonModGlossary, AddonModGlossary,
AddonModGlossaryEntry, AddonModGlossaryEntry,
AddonModGlossaryGlossary, AddonModGlossaryGlossary,
AddonModGlossaryProvider, AddonModGlossaryProvider,
GLOSSARY_ENTRY_UPDATED,
} from '../../services/glossary'; } from '../../services/glossary';
/** /**
@ -54,11 +56,13 @@ export class AddonModGlossaryEntryPage implements OnInit, OnDestroy {
offlineEntry?: AddonModGlossaryOfflineEntry; offlineEntry?: AddonModGlossaryOfflineEntry;
entries!: AddonModGlossaryEntryEntriesSwipeManager; entries!: AddonModGlossaryEntryEntriesSwipeManager;
glossary?: AddonModGlossaryGlossary; glossary?: AddonModGlossaryGlossary;
entryUpdatedObserver?: CoreEventObserver;
loaded = false; loaded = false;
showAuthor = false; showAuthor = false;
showDate = false; showDate = false;
ratingInfo?: CoreRatingInfo; ratingInfo?: CoreRatingInfo;
tagsEnabled = false; tagsEnabled = false;
canEdit = false;
canDelete = false; canDelete = false;
commentsEnabled = false; commentsEnabled = false;
courseId!: number; courseId!: number;
@ -75,10 +79,8 @@ export class AddonModGlossaryEntryPage implements OnInit, OnDestroy {
*/ */
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
let onlineEntryId: number | null = null; let onlineEntryId: number | null = null;
let offlineEntry: { let offlineEntryTimeCreated: number | null = null;
concept: string;
timecreated: number;
} | null = null;
try { try {
this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId');
this.tagsEnabled = CoreTag.areTagsAvailableInSite(); this.tagsEnabled = CoreTag.areTagsAvailableInSite();
@ -97,10 +99,7 @@ export class AddonModGlossaryEntryPage implements OnInit, OnDestroy {
await this.entries.start(); await this.entries.start();
if (entrySlug.startsWith('new-')) { if (entrySlug.startsWith('new-')) {
offlineEntry = { offlineEntryTimeCreated = Number(entrySlug.slice(4));
concept : CoreNavigator.getRequiredRouteParam<string>('concept'),
timecreated: Number(entrySlug.slice(4)),
};
} else { } else {
onlineEntryId = Number(entrySlug); onlineEntryId = Number(entrySlug);
} }
@ -111,6 +110,19 @@ export class AddonModGlossaryEntryPage implements OnInit, OnDestroy {
return; return;
} }
this.entryUpdatedObserver = CoreEvents.on(GLOSSARY_ENTRY_UPDATED, data => {
if (data.glossaryId !== this.glossary?.id) {
return;
}
if (
(this.onlineEntry && this.onlineEntry.id === data.entryId) ||
(this.offlineEntry && this.offlineEntry.timecreated === data.timecreated)
) {
this.doRefresh();
}
});
try { try {
if (onlineEntryId) { if (onlineEntryId) {
await this.loadOnlineEntry(onlineEntryId); await this.loadOnlineEntry(onlineEntryId);
@ -120,8 +132,8 @@ export class AddonModGlossaryEntryPage implements OnInit, OnDestroy {
} }
await CoreUtils.ignoreErrors(AddonModGlossary.logEntryView(onlineEntryId, this.componentId, this.glossary?.name)); await CoreUtils.ignoreErrors(AddonModGlossary.logEntryView(onlineEntryId, this.componentId, this.glossary?.name));
} else if (offlineEntry) { } else if (offlineEntryTimeCreated) {
await this.loadOfflineEntry(offlineEntry.concept, offlineEntry.timecreated); await this.loadOfflineEntry(offlineEntryTimeCreated);
} }
} finally { } finally {
this.loaded = true; this.loaded = true;
@ -133,6 +145,14 @@ export class AddonModGlossaryEntryPage implements OnInit, OnDestroy {
*/ */
ngOnDestroy(): void { ngOnDestroy(): void {
this.entries.destroy(); this.entries.destroy();
this.entryUpdatedObserver?.off();
}
/**
* Edit entry.
*/
async editEntry(): Promise<void> {
await CoreNavigator.navigate('./edit');
} }
/** /**
@ -168,7 +188,7 @@ export class AddonModGlossaryEntryPage implements OnInit, OnDestroy {
const concept = this.offlineEntry.concept; const concept = this.offlineEntry.concept;
const timecreated = this.offlineEntry.timecreated; const timecreated = this.offlineEntry.timecreated;
await AddonModGlossaryOffline.deleteOfflineEntry(glossaryId, concept, timecreated); await AddonModGlossaryOffline.deleteOfflineEntry(glossaryId, timecreated);
await AddonModGlossaryHelper.deleteStoredFiles(glossaryId, concept, timecreated); await AddonModGlossaryHelper.deleteStoredFiles(glossaryId, concept, timecreated);
} }
@ -234,14 +254,14 @@ export class AddonModGlossaryEntryPage implements OnInit, OnDestroy {
/** /**
* Load offline entry data. * Load offline entry data.
* *
* @param concept Entry concept.
* @param timecreated Entry Timecreated. * @param timecreated Entry Timecreated.
*/ */
protected async loadOfflineEntry(concept: string, timecreated: number): Promise<void> { protected async loadOfflineEntry(timecreated: number): Promise<void> {
try { try {
const glossary = await this.loadGlossary(); const glossary = await this.loadGlossary();
this.offlineEntry = await AddonModGlossaryOffline.getOfflineEntry(glossary.id, concept, timecreated); this.offlineEntry = await AddonModGlossaryOffline.getOfflineEntry(glossary.id, timecreated);
this.canEdit = true;
this.canDelete = true; this.canDelete = true;
} catch (error) { } catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'addon.mod_glossary.errorloadingentry', true); CoreDomUtils.showErrorModalDefault(error, 'addon.mod_glossary.errorloadingentry', true);

View File

@ -21,7 +21,7 @@ import { makeSingleton } from '@singletons';
import { CoreEvents } from '@singletons/events'; import { CoreEvents } from '@singletons/events';
import { CorePath } from '@singletons/path'; import { CorePath } from '@singletons/path';
import { AddonModGlossaryOfflineEntryDBRecord, OFFLINE_ENTRIES_TABLE_NAME } from './database/glossary'; import { AddonModGlossaryOfflineEntryDBRecord, OFFLINE_ENTRIES_TABLE_NAME } from './database/glossary';
import { AddonModGlossaryEntryOption, GLOSSARY_ENTRY_ADDED, GLOSSARY_ENTRY_DELETED } from './glossary'; import { AddonModGlossaryEntryOption, GLOSSARY_ENTRY_ADDED, GLOSSARY_ENTRY_DELETED, GLOSSARY_ENTRY_UPDATED } from './glossary';
/** /**
* Service to handle offline glossary. * Service to handle offline glossary.
@ -33,18 +33,16 @@ export class AddonModGlossaryOfflineProvider {
* Delete an offline entry. * Delete an offline entry.
* *
* @param glossaryId Glossary ID. * @param glossaryId Glossary ID.
* @param concept Glossary entry concept.
* @param timecreated The time the entry was created. * @param timecreated The time the entry was created.
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @returns Promise resolved if deleted, rejected if failure. * @returns Promise resolved if deleted, rejected if failure.
*/ */
async deleteOfflineEntry(glossaryId: number, concept: string, timecreated: number, siteId?: string): Promise<void> { async deleteOfflineEntry(glossaryId: number, timecreated: number, siteId?: string): Promise<void> {
const site = await CoreSites.getSite(siteId); const site = await CoreSites.getSite(siteId);
const conditions: Partial<AddonModGlossaryOfflineEntryDBRecord> = { const conditions: Partial<AddonModGlossaryOfflineEntryDBRecord> = {
glossaryid: glossaryId, glossaryid: glossaryId,
concept: concept, timecreated: timecreated,
timecreated,
}; };
await site.getDb().deleteRecords(OFFLINE_ENTRIES_TABLE_NAME, conditions); await site.getDb().deleteRecords(OFFLINE_ENTRIES_TABLE_NAME, conditions);
@ -70,14 +68,12 @@ export class AddonModGlossaryOfflineProvider {
* Get a stored offline entry. * Get a stored offline entry.
* *
* @param glossaryId Glossary ID. * @param glossaryId Glossary ID.
* @param concept Glossary entry concept.
* @param timeCreated The time the entry was created. * @param timeCreated The time the entry was created.
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @returns Promise resolved with entry. * @returns Promise resolved with entry.
*/ */
async getOfflineEntry( async getOfflineEntry(
glossaryId: number, glossaryId: number,
concept: string,
timeCreated: number, timeCreated: number,
siteId?: string, siteId?: string,
): Promise<AddonModGlossaryOfflineEntry> { ): Promise<AddonModGlossaryOfflineEntry> {
@ -85,7 +81,6 @@ export class AddonModGlossaryOfflineProvider {
const conditions: Partial<AddonModGlossaryOfflineEntryDBRecord> = { const conditions: Partial<AddonModGlossaryOfflineEntryDBRecord> = {
glossaryid: glossaryId, glossaryid: glossaryId,
concept: concept,
timecreated: timeCreated, timecreated: timeCreated,
}; };
@ -145,7 +140,7 @@ export class AddonModGlossaryOfflineProvider {
} }
// If there's only one entry, check that is not the one we are editing. // If there's only one entry, check that is not the one we are editing.
return CoreUtils.promiseFails(this.getOfflineEntry(glossaryId, concept, timeCreated, siteId)); return entries[0].timecreated !== timeCreated;
} catch { } catch {
// No offline data found, return false. // No offline data found, return false.
return false; return false;
@ -159,12 +154,11 @@ export class AddonModGlossaryOfflineProvider {
* @param concept Glossary entry concept. * @param concept Glossary entry concept.
* @param definition Glossary entry concept definition. * @param definition Glossary entry concept definition.
* @param courseId Course ID of the glossary. * @param courseId Course ID of the glossary.
* @param timecreated The time the entry was created. If not defined, current time.
* @param options Options for the entry. * @param options Options for the entry.
* @param attachments Result of CoreFileUploaderProvider#storeFilesToUpload for attachments. * @param attachments Result of CoreFileUploaderProvider#storeFilesToUpload for attachments.
* @param timecreated The time the entry was created. If not defined, current time.
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @param userId User the entry belong to. If not defined, current user in site. * @param userId User the entry belong to. If not defined, current user in site.
* @param discardEntry The entry provided will be discarded if found.
* @returns Promise resolved if stored, rejected if failure. * @returns Promise resolved if stored, rejected if failure.
*/ */
async addOfflineEntry( async addOfflineEntry(
@ -172,15 +166,13 @@ export class AddonModGlossaryOfflineProvider {
concept: string, concept: string,
definition: string, definition: string,
courseId: number, courseId: number,
timecreated: number,
options?: Record<string, AddonModGlossaryEntryOption>, options?: Record<string, AddonModGlossaryEntryOption>,
attachments?: CoreFileUploaderStoreFilesResult, attachments?: CoreFileUploaderStoreFilesResult,
timecreated?: number,
siteId?: string, siteId?: string,
userId?: number, userId?: number,
discardEntry?: AddonModGlossaryDiscardedEntry,
): Promise<false> { ): Promise<false> {
const site = await CoreSites.getSite(siteId); const site = await CoreSites.getSite(siteId);
timecreated = timecreated || Date.now();
const entry: AddonModGlossaryOfflineEntryDBRecord = { const entry: AddonModGlossaryOfflineEntryDBRecord = {
glossaryid: glossaryId, glossaryid: glossaryId,
@ -194,11 +186,6 @@ export class AddonModGlossaryOfflineProvider {
timecreated, timecreated,
}; };
// If editing an offline entry, delete previous first.
if (discardEntry) {
await this.deleteOfflineEntry(glossaryId, discardEntry.concept, discardEntry.timecreated, site.getId());
}
await site.getDb().insertRecord(OFFLINE_ENTRIES_TABLE_NAME, entry); await site.getDb().insertRecord(OFFLINE_ENTRIES_TABLE_NAME, entry);
CoreEvents.trigger(GLOSSARY_ENTRY_ADDED, { glossaryId, timecreated }, siteId); CoreEvents.trigger(GLOSSARY_ENTRY_ADDED, { glossaryId, timecreated }, siteId);
@ -206,6 +193,42 @@ export class AddonModGlossaryOfflineProvider {
return false; return false;
} }
/**
* Update an offline entry to be sent later.
*
* @param originalEntry Original entry data.
* @param concept Glossary entry concept.
* @param definition Glossary entry concept definition.
* @param options Options for the entry.
* @param attachments Result of CoreFileUploaderProvider#storeFilesToUpload for attachments.
*/
async updateOfflineEntry(
originalEntry: Pick< AddonModGlossaryOfflineEntryDBRecord, 'glossaryid'|'courseid'|'concept'|'timecreated'>,
concept: string,
definition: string,
options?: Record<string, AddonModGlossaryEntryOption>,
attachments?: CoreFileUploaderStoreFilesResult,
): Promise<void> {
const site = await CoreSites.getSite();
const entry: Omit<AddonModGlossaryOfflineEntryDBRecord, 'courseid'|'glossaryid'|'userid'|'timecreated'> = {
concept: concept,
definition: definition,
definitionformat: 'html',
options: JSON.stringify(options || {}),
attachments: JSON.stringify(attachments),
};
await site.getDb().updateRecords(OFFLINE_ENTRIES_TABLE_NAME, entry, {
...originalEntry,
userid: site.getUserId(),
});
CoreEvents.trigger(GLOSSARY_ENTRY_UPDATED, {
glossaryId: originalEntry.glossaryid,
timecreated: originalEntry.timecreated,
});
}
/** /**
* Get the path to the folder where to store files for offline attachments in a glossary. * Get the path to the folder where to store files for offline attachments in a glossary.
* *

View File

@ -285,7 +285,7 @@ export class AddonModGlossarySyncProvider extends CoreCourseActivitySyncBaseProv
*/ */
protected async deleteAddEntry(glossaryId: number, concept: string, timeCreated: number, siteId?: string): Promise<void> { protected async deleteAddEntry(glossaryId: number, concept: string, timeCreated: number, siteId?: string): Promise<void> {
await Promise.all([ await Promise.all([
AddonModGlossaryOffline.deleteOfflineEntry(glossaryId, concept, timeCreated, siteId), AddonModGlossaryOffline.deleteOfflineEntry(glossaryId, timeCreated, siteId),
AddonModGlossaryHelper.deleteStoredFiles(glossaryId, concept, timeCreated, siteId), AddonModGlossaryHelper.deleteStoredFiles(glossaryId, concept, timeCreated, siteId),
]); ]);
} }

View File

@ -30,6 +30,7 @@ import { AddonModGlossaryEntryDBRecord, ENTRIES_TABLE_NAME } from './database/gl
import { AddonModGlossaryOffline } from './glossary-offline'; import { AddonModGlossaryOffline } from './glossary-offline';
export const GLOSSARY_ENTRY_ADDED = 'addon_mod_glossary_entry_added'; export const GLOSSARY_ENTRY_ADDED = 'addon_mod_glossary_entry_added';
export const GLOSSARY_ENTRY_UPDATED = 'addon_mod_glossary_entry_updated';
export const GLOSSARY_ENTRY_DELETED = 'addon_mod_glossary_entry_deleted'; export const GLOSSARY_ENTRY_DELETED = 'addon_mod_glossary_entry_deleted';
/** /**
@ -806,13 +807,10 @@ export class AddonModGlossaryProvider {
// Convenience function to store a new entry to be synchronized later. // Convenience function to store a new entry to be synchronized later.
const storeOffline = async (): Promise<false> => { const storeOffline = async (): Promise<false> => {
const discardTime = otherOptions.discardEntry?.timecreated;
if (otherOptions.checkDuplicates) { if (otherOptions.checkDuplicates) {
// Check if the entry is duplicated in online or offline mode. // Check if the entry is duplicated in online or offline mode.
const conceptUsed = await this.isConceptUsed(glossaryId, concept, { const conceptUsed = await this.isConceptUsed(glossaryId, concept, {
cmId: otherOptions.cmId, cmId: otherOptions.cmId,
timeCreated: discardTime,
siteId: otherOptions.siteId, siteId: otherOptions.siteId,
}); });
@ -831,12 +829,11 @@ export class AddonModGlossaryProvider {
concept, concept,
definition, definition,
courseId, courseId,
otherOptions.timeCreated ?? Date.now(),
entryOptions, entryOptions,
attachments, attachments,
otherOptions.timeCreated,
otherOptions.siteId, otherOptions.siteId,
undefined, undefined,
otherOptions.discardEntry,
); );
return false; return false;
@ -847,16 +844,6 @@ export class AddonModGlossaryProvider {
return storeOffline(); return storeOffline();
} }
// If we are editing an offline entry, discard previous first.
if (otherOptions.discardEntry) {
await AddonModGlossaryOffline.deleteOfflineEntry(
glossaryId,
otherOptions.discardEntry.concept,
otherOptions.discardEntry.timecreated,
otherOptions.siteId,
);
}
try { try {
// Try to add it in online. // Try to add it in online.
const entryId = await this.addEntryOnline( const entryId = await this.addEntryOnline(
@ -1071,6 +1058,7 @@ declare module '@singletons/events' {
*/ */
export interface CoreEventsData { export interface CoreEventsData {
[GLOSSARY_ENTRY_ADDED]: AddonModGlossaryEntryAddedEventData; [GLOSSARY_ENTRY_ADDED]: AddonModGlossaryEntryAddedEventData;
[GLOSSARY_ENTRY_UPDATED]: AddonModGlossaryEntryUpdatedEventData;
[GLOSSARY_ENTRY_DELETED]: AddonModGlossaryEntryDeletedEventData; [GLOSSARY_ENTRY_DELETED]: AddonModGlossaryEntryDeletedEventData;
} }
@ -1085,12 +1073,22 @@ export type AddonModGlossaryEntryAddedEventData = {
timecreated?: number; timecreated?: number;
}; };
/**
* GLOSSARY_ENTRY_UPDATED event payload.
*/
export type AddonModGlossaryEntryUpdatedEventData = {
glossaryId: number;
entryId?: number;
timecreated?: number;
};
/** /**
* GLOSSARY_ENTRY_DELETED event payload. * GLOSSARY_ENTRY_DELETED event payload.
*/ */
export type AddonModGlossaryEntryDeletedEventData = { export type AddonModGlossaryEntryDeletedEventData = {
glossaryId: number; glossaryId: number;
entryId: number; entryId?: number;
timecreated?: number;
}; };
/** /**
@ -1361,21 +1359,12 @@ export type AddonModGlossaryViewEntryWSParams = {
*/ */
export type AddonModGlossaryAddEntryOptions = { export type AddonModGlossaryAddEntryOptions = {
timeCreated?: number; // The time the entry was created. If not defined, current time. timeCreated?: number; // The time the entry was created. If not defined, current time.
discardEntry?: AddonModGlossaryDiscardedEntry; // The entry provided will be discarded if found.
allowOffline?: boolean; // True if it can be stored in offline, false otherwise. allowOffline?: boolean; // True if it can be stored in offline, false otherwise.
checkDuplicates?: boolean; // Check for duplicates before storing offline. Only used if allowOffline is true. checkDuplicates?: boolean; // Check for duplicates before storing offline. Only used if allowOffline is true.
cmId?: number; // Module ID. cmId?: number; // Module ID.
siteId?: string; // Site ID. If not defined, current site. siteId?: string; // Site ID. If not defined, current site.
}; };
/**
* Entry to discard.
*/
export type AddonModGlossaryDiscardedEntry = {
concept: string;
timecreated: number;
};
/** /**
* Options to pass to the different get entries functions. * Options to pass to the different get entries functions.
*/ */

View File

@ -154,7 +154,6 @@ Feature: Test basic usage of glossary in app
Then I should find "Garlic" in the app Then I should find "Garlic" in the app
And I should find "Allium sativum" in the app And I should find "Allium sativum" in the app
@noeldebug
Scenario: Edit entries (basic info) Scenario: Edit entries (basic info)
Given I entered the glossary activity "Test glossary" on course "Course 1" as "student1" in the app Given I entered the glossary activity "Test glossary" on course "Course 1" as "student1" in the app
@ -166,6 +165,9 @@ Feature: Test basic usage of glossary in app
And I set the following fields to these values in the app: And I set the following fields to these values in the app:
| Concept | Broccoli | | Concept | Broccoli |
| Definition | Brassica oleracea var. italica | | Definition | Brassica oleracea var. italica |
And I press "This entry should be automatically linked" "ion-toggle" in the app
And I press "This entry is case sensitive" "ion-toggle" in the app
And I press "Match whole words only" "ion-toggle" in the app
And I press "Save" in the app And I press "Save" in the app
Then I should find "Potato" in the app Then I should find "Potato" in the app
And I should find "Broccoli" in the app And I should find "Broccoli" in the app
@ -176,6 +178,9 @@ Feature: Test basic usage of glossary in app
When I press "Edit entry" in the app When I press "Edit entry" in the app
Then the field "Concept" matches value "Broccoli" in the app Then the field "Concept" matches value "Broccoli" in the app
And the field "Definition" matches value "Brassica oleracea var. italica" in the app And the field "Definition" matches value "Brassica oleracea var. italica" in the app
And "This entry should be automatically linked" "ion-toggle" should be selected in the app
And "This entry is case sensitive" "ion-toggle" should be selected in the app
And "Match whole words only" "ion-toggle" should be selected in the app
When I set the following fields to these values in the app: When I set the following fields to these values in the app:
| Concept | Pickle | | Concept | Pickle |
@ -189,9 +194,6 @@ Feature: Test basic usage of glossary in app
And I should find "Potato" in the app And I should find "Potato" in the app
But I should not find "Broccoli" in the app But I should not find "Broccoli" in the app
# TODO test attachments? (yes, in all scenarios!!)
# TODO And I upload "stub.txt" to "File" ".action-sheet-button" in the app
Scenario: Delete entries Scenario: Delete entries
Given I entered the glossary activity "Test glossary" on course "Course 1" as "student1" in the app Given I entered the glossary activity "Test glossary" on course "Course 1" as "student1" in the app

View File

@ -200,6 +200,17 @@ Feature: Test glossary navigation
When I swipe to the left in the app When I swipe to the left in the app
Then I should find "Acerola is a fruit" in the app Then I should find "Acerola is a fruit" in the app
# Edit
When I swipe to the right in the app
And I press "Edit entry" in the app
And I press "Save" in the app
Then I should find "Tomato is a fruit" in the app
When I press the back button in the app
Then I should find "Tomato" in the app
And I should find "Cashew" in the app
And I should find "Acerola" in the app
@ci_jenkins_skip @ci_jenkins_skip
Scenario: Tablet navigation on glossary Scenario: Tablet navigation on glossary
Given I entered the course "Course 1" as "student1" in the app Given I entered the course "Course 1" as "student1" in the app
@ -301,3 +312,12 @@ Feature: Test glossary navigation
When I press "Acerola" in the app When I press "Acerola" in the app
Then "Acerola" near "Tomato" should be selected in the app Then "Acerola" near "Tomato" should be selected in the app
And I should find "Acerola is a fruit" inside the split-view content in the app And I should find "Acerola is a fruit" inside the split-view content in the app
# Edit
When I press "Tomato" in the app
And I press "Edit entry" in the app
And I press "Save" in the app
Then I should find "Tomato is a fruit" inside the split-view content in the app
And I should find "Tomato" in the app
And I should find "Cashew" in the app
And I should find "Acerola" in the app