Merge pull request #3594 from NoelDeMartin/MOBILE-2652

MOBILE-2652: Glossary edit & delete entries
main
Dani Palou 2023-04-14 08:16:02 +02:00 committed by GitHub
commit 28b48ac524
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1494 additions and 845 deletions

View File

@ -71,5 +71,14 @@ gulp.task('watch', () => {
}); });
gulp.task('watch-behat', () => { gulp.task('watch-behat', () => {
gulp.watch(['./src/**/*.feature', './src/**/*.png', './local_moodleappbehat'], { interval: 500 }, gulp.parallel('behat')); gulp.watch(
[
'./src/**/*.feature',
'./src/**/tests/behat/fixtures/**',
'./src/**/tests/behat/snapshots/**',
'./local_moodleappbehat',
],
{ interval: 500 },
gulp.parallel('behat')
);
}); });

View File

@ -44,27 +44,21 @@ class behat_app extends behat_app_helper {
], ],
]; ];
protected $featurepath = '';
protected $windowsize = '360x720'; protected $windowsize = '360x720';
/** /**
* @BeforeScenario * @BeforeScenario
*/ */
public function before_scenario(ScenarioScope $scope) { public function before_scenario(ScenarioScope $scope) {
if (!$scope->getFeature()->hasTag('app')) { $feature = $scope->getFeature();
if (!$feature->hasTag('app')) {
return; return;
} }
global $CFG; $this->featurepath = dirname($feature->getFile());
$this->configure_performance_logs();
$performanceLogs = $CFG->behat_profiles['default']['capabilities']['extra_capabilities']['goog:loggingPrefs']['performance'] ?? null;
if ($performanceLogs !== 'ALL') {
return;
}
// Enable DB Logging only for app tests with performance logs activated.
$this->getSession()->visit($this->get_app_url() . '/assets/env.json');
$this->execute_script("document.cookie = 'MoodleAppDBLoggingEnabled=true;path=/';");
} }
/** /**
@ -89,6 +83,23 @@ class behat_app extends behat_app_helper {
$this->enter_site(); $this->enter_site();
} }
/**
* Configure performance logs.
*/
protected function configure_performance_logs() {
global $CFG;
$performanceLogs = $CFG->behat_profiles['default']['capabilities']['extra_capabilities']['goog:loggingPrefs']['performance'] ?? null;
if ($performanceLogs !== 'ALL') {
return;
}
// Enable DB Logging only for app tests with performance logs activated.
$this->getSession()->visit($this->get_app_url() . '/assets/env.json');
$this->execute_script("document.cookie = 'MoodleAppDBLoggingEnabled=true;path=/';");
}
/** /**
* Check whether the current page is the login form. * Check whether the current page is the login form.
*/ */
@ -778,6 +789,35 @@ class behat_app extends behat_app_helper {
} }
} }
/**
* Uploads a file to a file input, the file path should be relative to a fixtures folder next to the feature file.
* The ìnput locator can match a container with a file input inside, it doesn't have to be the input itself.
*
* @Given /^I upload "((?:[^"]|\\")+)" to (".+") in the app$/
* @param string $filename
* @param string $inputlocator
*/
public function i_upload_a_file_in_the_app(string $filename, string $inputlocator) {
$filepath = str_replace('/', DIRECTORY_SEPARATOR, "{$this->featurepath}/fixtures/$filename");
$inputlocator = $this->parse_element_locator($inputlocator);
$id = $this->spin(function() use ($inputlocator) {
$result = $this->runtime_js("getFileInputId($inputlocator)");
if (str_starts_with($result, 'ERROR')) {
throw new DriverException('Error finding input - ' . $result);
}
return $result;
});
$this->wait_for_pending_js();
$fileinput = $this ->getSession()->getPage()->findById($id);
$fileinput->attachFile($filepath);
}
/** /**
* Checks a field matches a certain value in the app. * Checks a field matches a certain value in the app.
* *

View File

@ -33,7 +33,7 @@ async function main() {
: []; : [];
if (!existsSync(pluginPath)) { if (!existsSync(pluginPath)) {
mkdirSync(pluginPath); mkdirSync(pluginPath, { recursive: true });
} else { } else {
// Empty directory, except the excluding list. // Empty directory, except the excluding list.
const excludeFromErase = [ const excludeFromErase = [
@ -76,21 +76,29 @@ async function main() {
}; };
writeFileSync(pluginFilePath, replaceArguments(fileContents, replacements)); writeFileSync(pluginFilePath, replaceArguments(fileContents, replacements));
// Copy feature and snapshot files. // Copy features, snapshots, and fixtures.
if (!excludeFeatures) { if (!excludeFeatures) {
const behatTempFeaturesPath = `${pluginPath}/behat-tmp`; const behatTempFeaturesPath = `${pluginPath}/behat-tmp`;
copySync(projectPath('src'), behatTempFeaturesPath, { filter: shouldCopyFileOrDirectory }); copySync(projectPath('src'), behatTempFeaturesPath, { filter: shouldCopyFileOrDirectory });
const behatFeaturesPath = `${pluginPath}/tests/behat`; const behatFeaturesPath = `${pluginPath}/tests/behat`;
if (!existsSync(behatFeaturesPath)) { if (!existsSync(behatFeaturesPath)) {
mkdirSync(behatFeaturesPath, {recursive: true}); mkdirSync(behatFeaturesPath, { recursive: true });
} }
for await (const file of getDirectoryFiles(behatTempFeaturesPath)) { for await (const file of getDirectoryFiles(behatTempFeaturesPath)) {
const filePath = dirname(file); const filePath = dirname(file);
const snapshotsIndex = file.indexOf('/tests/behat/snapshots/');
const fixturesIndex = file.indexOf('/tests/behat/fixtures/');
if (filePath.endsWith('/tests/behat/snapshots')) { if (snapshotsIndex !== -1) {
renameSync(file, behatFeaturesPath + '/snapshots/' + basename(file)); moveFile(file, behatFeaturesPath + '/snapshots/' + file.slice(snapshotsIndex + 23));
continue;
}
if (fixturesIndex !== -1) {
moveFile(file, behatFeaturesPath + '/fixtures/' + file.slice(fixturesIndex + 22));
continue; continue;
} }
@ -103,7 +111,7 @@ async function main() {
const searchRegExp = /\//g; const searchRegExp = /\//g;
const prefix = relative(behatTempFeaturesPath, newPath).replace(searchRegExp,'-') || 'core'; const prefix = relative(behatTempFeaturesPath, newPath).replace(searchRegExp,'-') || 'core';
const featureFilename = prefix + '-' + basename(file); const featureFilename = prefix + '-' + basename(file);
renameSync(file, behatFeaturesPath + '/' + featureFilename); moveFile(file, behatFeaturesPath + '/' + featureFilename);
} }
rmSync(behatTempFeaturesPath, {recursive: true}); rmSync(behatTempFeaturesPath, {recursive: true});
@ -115,7 +123,8 @@ function shouldCopyFileOrDirectory(path) {
return stats.isDirectory() return stats.isDirectory()
|| extname(path) === '.feature' || extname(path) === '.feature'
|| extname(path) === '.png'; || path.includes('/tests/behat/snapshots')
|| path.includes('/tests/behat/fixtures');
} }
function isExcluded(file, exclusions) { function isExcluded(file, exclusions) {
@ -127,6 +136,16 @@ function fail(message) {
process.exit(1); process.exit(1);
} }
function moveFile(from, to) {
const targetDir = dirname(to);
if (!existsSync(targetDir)) {
mkdirSync(targetDir, { recursive: true });
}
renameSync(from, to);
}
function guessPluginPath() { function guessPluginPath() {
if (process.env.MOODLE_APP_BEHAT_PLUGIN_PATH) { if (process.env.MOODLE_APP_BEHAT_PLUGIN_PATH) {
return process.env.MOODLE_APP_BEHAT_PLUGIN_PATH; return process.env.MOODLE_APP_BEHAT_PLUGIN_PATH;

View File

@ -681,6 +681,7 @@
"addon.mod_forum.yourreply": "forum", "addon.mod_forum.yourreply": "forum",
"addon.mod_glossary.addentry": "glossary", "addon.mod_glossary.addentry": "glossary",
"addon.mod_glossary.aliases": "glossary", "addon.mod_glossary.aliases": "glossary",
"addon.mod_glossary.areyousuredelete": "glossary",
"addon.mod_glossary.attachment": "glossary", "addon.mod_glossary.attachment": "glossary",
"addon.mod_glossary.browsemode": "local_moodlemobileapp", "addon.mod_glossary.browsemode": "local_moodlemobileapp",
"addon.mod_glossary.byalphabet": "local_moodlemobileapp", "addon.mod_glossary.byalphabet": "local_moodlemobileapp",
@ -694,9 +695,14 @@
"addon.mod_glossary.categories": "glossary", "addon.mod_glossary.categories": "glossary",
"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.editentry": "glossary",
"addon.mod_glossary.entriestobesynced": "local_moodlemobileapp", "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.entrypendingapproval": "local_moodlemobileapp",
"addon.mod_glossary.entryusedynalink": "glossary", "addon.mod_glossary.entryusedynalink": "glossary",
"addon.mod_glossary.errordeleting": "local_moodlemobileapp",
"addon.mod_glossary.errconceptalreadyexists": "glossary", "addon.mod_glossary.errconceptalreadyexists": "glossary",
"addon.mod_glossary.errorloadingentries": "local_moodlemobileapp", "addon.mod_glossary.errorloadingentries": "local_moodlemobileapp",
"addon.mod_glossary.errorloadingentry": "local_moodlemobileapp", "addon.mod_glossary.errorloadingentry": "local_moodlemobileapp",

View File

@ -29,8 +29,6 @@ import { AddonModGlossaryOffline, AddonModGlossaryOfflineEntry } from '../servic
*/ */
export class AddonModGlossaryEntriesSource extends CoreRoutedItemsManagerSource<AddonModGlossaryEntryItem> { export class AddonModGlossaryEntriesSource extends CoreRoutedItemsManagerSource<AddonModGlossaryEntryItem> {
static readonly NEW_ENTRY: AddonModGlossaryNewEntryForm = { newEntry: true };
readonly COURSE_ID: number; readonly COURSE_ID: number;
readonly CM_ID: number; readonly CM_ID: number;
readonly GLOSSARY_PATH_PREFIX: string; readonly GLOSSARY_PATH_PREFIX: string;
@ -54,16 +52,6 @@ export class AddonModGlossaryEntriesSource extends CoreRoutedItemsManagerSource<
this.GLOSSARY_PATH_PREFIX = glossaryPathPrefix; 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. * Type guard to infer entry objects.
* *
@ -81,38 +69,28 @@ export class AddonModGlossaryEntriesSource extends CoreRoutedItemsManagerSource<
* @returns Whether the item is an offline entry. * @returns Whether the item is an offline entry.
*/ */
isOfflineEntry(entry: AddonModGlossaryEntryItem): entry is AddonModGlossaryOfflineEntry { isOfflineEntry(entry: AddonModGlossaryEntryItem): entry is AddonModGlossaryOfflineEntry {
return !this.isNewEntryForm(entry) && !this.isOnlineEntry(entry); return !this.isOnlineEntry(entry);
} }
/** /**
* @inheritdoc * @inheritdoc
*/ */
getItemPath(entry: AddonModGlossaryEntryItem): string { getItemPath(entry: AddonModGlossaryEntryItem): string {
if (this.isOnlineEntry(entry)) {
return `${this.GLOSSARY_PATH_PREFIX}entry/${entry.id}`;
}
if (this.isOfflineEntry(entry)) { 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}`;
} }
/** /**
* @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;
} }
/** /**
@ -164,21 +142,8 @@ export class AddonModGlossaryEntriesSource extends CoreRoutedItemsManagerSource<
const glossaryId = this.glossary.id; const glossaryId = this.glossary.id;
this.fetchFunction = (options) => AddonModGlossary.getEntriesBySearch( this.fetchFunction = (options) => AddonModGlossary.getEntriesBySearch(glossaryId, query, true, options);
glossaryId, this.fetchInvalidate = () => AddonModGlossary.invalidateEntriesBySearch(glossaryId, query, true);
query,
true,
'CONCEPT',
'ASC',
options,
);
this.fetchInvalidate = () => AddonModGlossary.invalidateEntriesBySearch(
glossaryId,
query,
true,
'CONCEPT',
'ASC',
);
this.hasSearched = true; this.hasSearched = true;
this.setDirty(true); this.setDirty(true);
} }
@ -192,12 +157,14 @@ export class AddonModGlossaryEntriesSource extends CoreRoutedItemsManagerSource<
/** /**
* Invalidate glossary cache. * Invalidate glossary cache.
*
* @param invalidateGlossary Whether to invalidate the entire glossary or not
*/ */
async invalidateCache(): Promise<void> { async invalidateCache(invalidateGlossary: boolean = true): Promise<void> {
await Promise.all([ await Promise.all<unknown>([
AddonModGlossary.invalidateCourseGlossaries(this.COURSE_ID),
this.fetchInvalidate && this.fetchInvalidate(), this.fetchInvalidate && this.fetchInvalidate(),
this.glossary && AddonModGlossary.invalidateCategories(this.glossary.id), invalidateGlossary && AddonModGlossary.invalidateCourseGlossaries(this.COURSE_ID),
invalidateGlossary && this.glossary && AddonModGlossary.invalidateCategories(this.glossary.id),
]); ]);
} }
@ -220,65 +187,29 @@ export class AddonModGlossaryEntriesSource extends CoreRoutedItemsManagerSource<
case 'author_all': case 'author_all':
// Browse by author. // Browse by author.
this.viewMode = 'author'; this.viewMode = 'author';
this.fetchFunction = (options) => AddonModGlossary.getEntriesByAuthor( this.fetchFunction = (options) => AddonModGlossary.getEntriesByAuthor(glossaryId, options);
glossaryId, this.fetchInvalidate = () => AddonModGlossary.invalidateEntriesByAuthor(glossaryId);
'ALL',
'LASTNAME',
'ASC',
options,
);
this.fetchInvalidate = () => AddonModGlossary.invalidateEntriesByAuthor(
glossaryId,
'ALL',
'LASTNAME',
'ASC',
);
break; break;
case 'cat_all': case 'cat_all':
// Browse by category. // Browse by category.
this.viewMode = 'cat'; this.viewMode = 'cat';
this.fetchFunction = (options) => AddonModGlossary.getEntriesByCategory( this.fetchFunction = (options) => AddonModGlossary.getEntriesByCategory(glossaryId, options);
glossaryId, this.fetchInvalidate = () => AddonModGlossary.invalidateEntriesByCategory(glossaryId);
AddonModGlossaryProvider.SHOW_ALL_CATEGORIES,
options,
);
this.fetchInvalidate = () => AddonModGlossary.invalidateEntriesByCategory(
glossaryId,
AddonModGlossaryProvider.SHOW_ALL_CATEGORIES,
);
break; break;
case 'newest_first': case 'newest_first':
// Newest first. // Newest first.
this.viewMode = 'date'; this.viewMode = 'date';
this.fetchFunction = (options) => AddonModGlossary.getEntriesByDate( this.fetchFunction = (options) => AddonModGlossary.getEntriesByDate(glossaryId, 'CREATION', options);
glossaryId, this.fetchInvalidate = () => AddonModGlossary.invalidateEntriesByDate(glossaryId, 'CREATION');
'CREATION',
'DESC',
options,
);
this.fetchInvalidate = () => AddonModGlossary.invalidateEntriesByDate(
glossaryId,
'CREATION',
'DESC',
);
break; break;
case 'recently_updated': case 'recently_updated':
// Recently updated. // Recently updated.
this.viewMode = 'date'; this.viewMode = 'date';
this.fetchFunction = (options) => AddonModGlossary.getEntriesByDate( this.fetchFunction = (options) => AddonModGlossary.getEntriesByDate(glossaryId, 'UPDATE', options);
glossaryId, this.fetchInvalidate = () => AddonModGlossary.invalidateEntriesByDate(glossaryId, 'UPDATE');
'UPDATE',
'DESC',
options,
);
this.fetchInvalidate = () => AddonModGlossary.invalidateEntriesByDate(
glossaryId,
'UPDATE',
'DESC',
);
break; break;
case 'letter_all': case 'letter_all':
@ -286,15 +217,8 @@ export class AddonModGlossaryEntriesSource extends CoreRoutedItemsManagerSource<
// Consider it is 'letter_all'. // Consider it is 'letter_all'.
this.viewMode = 'letter'; this.viewMode = 'letter';
this.fetchMode = 'letter_all'; this.fetchMode = 'letter_all';
this.fetchFunction = (options) => AddonModGlossary.getEntriesByLetter( this.fetchFunction = (options) => AddonModGlossary.getEntriesByLetter(glossaryId, options);
glossaryId, this.fetchInvalidate = () => AddonModGlossary.invalidateEntriesByLetter(glossaryId);
'ALL',
options,
);
this.fetchInvalidate = () => AddonModGlossary.invalidateEntriesByLetter(
glossaryId,
'ALL',
);
break; break;
} }
} }
@ -313,11 +237,10 @@ export class AddonModGlossaryEntriesSource extends CoreRoutedItemsManagerSource<
const entries: AddonModGlossaryEntryItem[] = []; const entries: AddonModGlossaryEntryItem[] = [];
if (page === 0) { if (page === 0) {
const offlineEntries = await AddonModGlossaryOffline.getGlossaryNewEntries(glossary.id); const offlineEntries = await AddonModGlossaryOffline.getGlossaryOfflineEntries(glossary.id);
offlineEntries.sort((a, b) => a.concept.localeCompare(b.concept)); offlineEntries.sort((a, b) => a.concept.localeCompare(b.concept));
entries.push(AddonModGlossaryEntriesSource.NEW_ENTRY);
entries.push(...offlineEntries); entries.push(...offlineEntries);
} }
@ -369,12 +292,7 @@ export class AddonModGlossaryEntriesSource extends CoreRoutedItemsManagerSource<
/** /**
* Type of items that can be held by the entries manager. * Type of items that can be held by the entries manager.
*/ */
export type AddonModGlossaryEntryItem = AddonModGlossaryEntry | AddonModGlossaryOfflineEntry | AddonModGlossaryNewEntryForm; export type AddonModGlossaryEntryItem = AddonModGlossaryEntry | AddonModGlossaryOfflineEntry;
/**
* Type to select the new entry form.
*/
export type AddonModGlossaryNewEntryForm = { newEntry: true };
/** /**
* Fetch mode to sort entries. * 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()"> [componentId]="componentId" [courseId]="courseId" [hasDataToSync]="hasOffline" (completionChanged)="onCompletionChange()">
</core-course-module-info> </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-item-divider>
<ion-label> <ion-label>
<h2 class="big">{{ 'addon.mod_glossary.entriestobesynced' | translate }}</h2> <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 <ion-item *ngFor="let entry of entries.offlineEntries" (click)="entries.select(entry)" detail="false" button
[attr.aria-current]="entries.getItemAriaCurrent(entry)"> [attr.aria-current]="entries.getItemAriaCurrent(entry)">
<ion-label> <ion-label>
<core-format-text [text]="entry.concept" contextLevel="module" [contextInstanceId]="glossary!.coursemodule" <div class="addon-mod-glossary-index--offline-entry">
[courseId]="courseId"> <core-format-text [text]="entry.concept" contextLevel="module" [contextInstanceId]="glossary!.coursemodule"
</core-format-text> [courseId]="courseId">
</core-format-text>
<ion-icon name="fas-rotate" class="ion-margin-start" aria-hidden="true"></ion-icon>
</div>
</ion-label> </ion-label>
</ion-item> </ion-item>
</ion-list> </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 { CoreRatingOffline } from '@features/rating/services/rating-offline';
import { CoreRatingSyncProvider } from '@features/rating/services/rating-sync'; import { CoreRatingSyncProvider } from '@features/rating/services/rating-sync';
import { IonContent } from '@ionic/angular'; import { IonContent } from '@ionic/angular';
import { CoreNavigator } from '@services/navigator';
import { CoreSites } from '@services/sites'; import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreDomUtils } from '@services/utils/dom';
import { CoreTextUtils } from '@services/utils/text'; import { CoreTextUtils } from '@services/utils/text';
@ -42,12 +43,15 @@ import {
AddonModGlossaryEntryWithCategory, AddonModGlossaryEntryWithCategory,
AddonModGlossaryGlossary, AddonModGlossaryGlossary,
AddonModGlossaryProvider, AddonModGlossaryProvider,
GLOSSARY_ENTRY_ADDED,
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 {
AddonModGlossaryAutoSyncData, AddonModGlossaryAutoSyncedData,
AddonModGlossarySyncProvider,
AddonModGlossarySyncResult, AddonModGlossarySyncResult,
GLOSSARY_AUTO_SYNCED,
} from '../../services/glossary-sync'; } from '../../services/glossary-sync';
import { AddonModGlossaryModuleHandlerService } from '../../services/handlers/module'; import { AddonModGlossaryModuleHandlerService } from '../../services/handlers/module';
import { AddonModGlossaryPrefetchHandler } from '../../services/handlers/prefetch'; import { AddonModGlossaryPrefetchHandler } from '../../services/handlers/prefetch';
@ -59,6 +63,7 @@ import { AddonModGlossaryModePickerPopoverComponent } from '../mode-picker/mode-
@Component({ @Component({
selector: 'addon-mod-glossary-index', selector: 'addon-mod-glossary-index',
templateUrl: 'addon-mod-glossary-index.html', templateUrl: 'addon-mod-glossary-index.html',
styleUrls: ['index.scss'],
}) })
export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivityComponent export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivityComponent
implements OnInit, AfterViewInit, OnDestroy { implements OnInit, AfterViewInit, OnDestroy {
@ -75,13 +80,11 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
protected hasOfflineEntries = false; protected hasOfflineEntries = false;
protected hasOfflineRatings = false; protected hasOfflineRatings = false;
protected syncEventName = AddonModGlossarySyncProvider.AUTO_SYNCED; protected syncEventName = GLOSSARY_AUTO_SYNCED;
protected addEntryObserver?: CoreEventObserver;
protected fetchedEntriesCanLoadMore = false; protected fetchedEntriesCanLoadMore = false;
protected fetchedEntries: AddonModGlossaryEntry[] = []; protected fetchedEntries: AddonModGlossaryEntry[] = [];
protected sourceUnsubscribe?: () => void; protected sourceUnsubscribe?: () => void;
protected ratingOfflineObserver?: CoreEventObserver; protected observers?: CoreEventObserver[];
protected ratingSyncObserver?: CoreEventObserver;
protected checkCompletionAfterLog = false; // Use CoreListItemsManager log system instead. protected checkCompletionAfterLog = false; // Use CoreListItemsManager log system instead.
getDivider?: (entry: AddonModGlossaryEntry) => string; getDivider?: (entry: AddonModGlossaryEntry) => string;
@ -136,30 +139,48 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
}); });
// When an entry is added, we reload the data. // When an entry is added, we reload the data.
this.addEntryObserver = CoreEvents.on(AddonModGlossaryProvider.ADD_ENTRY_EVENT, (data) => { this.observers = [
if (this.glossary && this.glossary.id === data.glossaryId) { CoreEvents.on(GLOSSARY_ENTRY_ADDED, ({ glossaryId }) => {
this.showLoadingAndRefresh(false); if (this.glossary?.id !== glossaryId) {
return;
}
// Check completion since it could be configured to complete once the user adds a new entry. // Check completion since it could be configured to complete once the user adds a new entry.
this.checkCompletion(); this.checkCompletion();
}
}); this.showLoadingAndRefresh(false);
}),
CoreEvents.on(GLOSSARY_ENTRY_UPDATED, ({ glossaryId }) => {
if (this.glossary?.id !== glossaryId) {
return;
}
this.showLoadingAndRefresh(false);
}),
CoreEvents.on(GLOSSARY_ENTRY_DELETED, ({ glossaryId }) => {
if (this.glossary?.id !== glossaryId) {
return;
}
this.showLoadingAndRefresh(false);
}),
];
// Listen for offline ratings saved and synced. // Listen for offline ratings saved and synced.
this.ratingOfflineObserver = CoreEvents.on(CoreRatingProvider.RATING_SAVED_EVENT, (data) => { this.observers.push(CoreEvents.on(CoreRatingProvider.RATING_SAVED_EVENT, (data) => {
if (this.glossary && data.component == 'mod_glossary' && data.ratingArea == 'entry' && data.contextLevel == 'module' if (this.glossary && data.component == 'mod_glossary' && data.ratingArea == 'entry' && data.contextLevel == 'module'
&& data.instanceId == this.glossary.coursemodule) { && data.instanceId == this.glossary.coursemodule) {
this.hasOfflineRatings = true; this.hasOfflineRatings = true;
this.hasOffline = true; this.hasOffline = true;
} }
}); }));
this.ratingSyncObserver = CoreEvents.on(CoreRatingSyncProvider.SYNCED_EVENT, (data) => { this.observers.push(CoreEvents.on(CoreRatingSyncProvider.SYNCED_EVENT, (data) => {
if (this.glossary && data.component == 'mod_glossary' && data.ratingArea == 'entry' && data.contextLevel == 'module' if (this.glossary && data.component == 'mod_glossary' && data.ratingArea == 'entry' && data.contextLevel == 'module'
&& data.instanceId == this.glossary.coursemodule) { && data.instanceId == this.glossary.coursemodule) {
this.hasOfflineRatings = false; this.hasOfflineRatings = false;
this.hasOffline = this.hasOfflineEntries; this.hasOffline = this.hasOfflineEntries;
} }
}); }));
} }
/** /**
@ -227,7 +248,7 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
* @param syncEventData Data receiven on sync observer. * @param syncEventData Data receiven on sync observer.
* @returns True if refresh is needed, false otherwise. * @returns True if refresh is needed, false otherwise.
*/ */
protected isRefreshSyncNeeded(syncEventData: AddonModGlossaryAutoSyncData): boolean { protected isRefreshSyncNeeded(syncEventData: AddonModGlossaryAutoSyncedData): boolean {
return !!this.glossary && syncEventData.glossaryId == this.glossary.id && return !!this.glossary && syncEventData.glossaryId == this.glossary.id &&
syncEventData.userId == CoreSites.getCurrentSiteUserId(); syncEventData.userId == CoreSites.getCurrentSiteUserId();
} }
@ -388,7 +409,11 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
* Opens new entry editor. * Opens new entry editor.
*/ */
openNewEntry(): void { openNewEntry(): void {
this.entries?.select(AddonModGlossaryEntriesSource.NEW_ENTRY); CoreNavigator.navigate(
this.splitView.outletActivated
? '../new'
: './entry/new',
);
} }
/** /**
@ -410,9 +435,7 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
ngOnDestroy(): void { ngOnDestroy(): void {
super.ngOnDestroy(); super.ngOnDestroy();
this.addEntryObserver?.off(); this.observers?.forEach(observer => observer.off());
this.ratingOfflineObserver?.off();
this.ratingSyncObserver?.off();
this.sourceUnsubscribe?.call(null); this.sourceUnsubscribe?.call(null);
this.entries?.destroy(); this.entries?.destroy();
} }

View File

@ -27,13 +27,9 @@ const mobileRoutes: Routes = [
component: AddonModGlossaryIndexPage, component: AddonModGlossaryIndexPage,
}, },
{ {
path: ':courseId/:cmId/entry/:entryId', path: ':courseId/:cmId/entry/:entrySlug',
loadChildren: () => import('./glossary-entry-lazy.module').then(m => m.AddonModGlossaryEntryLazyModule), 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 = [ const tabletRoutes: Routes = [
@ -42,18 +38,22 @@ const tabletRoutes: Routes = [
component: AddonModGlossaryIndexPage, component: AddonModGlossaryIndexPage,
children: [ children: [
{ {
path: 'entry/:entryId', path: 'entry/:entrySlug',
loadChildren: () => import('./glossary-entry-lazy.module').then(m => m.AddonModGlossaryEntryLazyModule), 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 = [ const routes: Routes = [
{
path: ':courseId/:cmId/entry/new',
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

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

View File

@ -1,6 +1,7 @@
{ {
"addentry": "Add a new entry", "addentry": "Add a new entry",
"aliases": "Keyword(s)", "aliases": "Keyword(s)",
"areyousuredelete": "Are you sure you want to delete this entry?",
"attachment": "Attachment", "attachment": "Attachment",
"browsemode": "Browse entries", "browsemode": "Browse entries",
"byalphabet": "Alphabetically", "byalphabet": "Alphabetically",
@ -14,10 +15,15 @@
"categories": "Categories", "categories": "Categories",
"concept": "Concept", "concept": "Concept",
"definition": "Definition", "definition": "Definition",
"deleteentry": "Delete entry",
"editentry": "Edit entry",
"entriestobesynced": "Entries to be synced", "entriestobesynced": "Entries to be synced",
"entry": "Entry",
"entrydeleted": "Entry deleted",
"entrypendingapproval": "This entry is pending approval.", "entrypendingapproval": "This entry is pending approval.",
"entryusedynalink": "This entry should be automatically linked", "entryusedynalink": "This entry should be automatically linked",
"errconceptalreadyexists": "This concept already exists. No duplicates allowed in this glossary.", "errconceptalreadyexists": "This concept already exists. No duplicates allowed in this glossary.",
"errordeleting": "Error deleting entry.",
"errorloadingentries": "An error occurred while loading entries.", "errorloadingentries": "An error occurred while loading entries.",
"errorloadingentry": "An error occurred while loading the entry.", "errorloadingentry": "An error occurred while loading the entry.",
"errorloadingglossary": "An error occurred while loading the glossary.", "errorloadingglossary": "An error occurred while loading the glossary.",

View File

@ -11,12 +11,12 @@
</ion-title> </ion-title>
</ion-toolbar> </ion-toolbar>
</ion-header> </ion-header>
<ion-content [core-swipe-navigation]="entries"> <ion-content>
<core-loading [hideUntil]="loaded"> <core-loading [hideUntil]="loaded">
<form #editFormEl *ngIf="glossary"> <form #editFormEl *ngIf="glossary">
<ion-item> <ion-item>
<ion-label position="stacked">{{ 'addon.mod_glossary.concept' | translate }}</ion-label> <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 type="text" [placeholder]="'addon.mod_glossary.concept' | translate" [(ngModel)]="data.concept" name="concept">
</ion-input> </ion-input>
</ion-item> </ion-item>
<ion-item> <ion-item>
@ -31,7 +31,7 @@
<ion-label position="stacked"> <ion-label position="stacked">
{{ 'addon.mod_glossary.categories' | translate }} {{ 'addon.mod_glossary.categories' | translate }}
</ion-label> </ion-label>
<ion-select [(ngModel)]="options.categories" multiple="true" interface="action-sheet" <ion-select [(ngModel)]="data.categories" multiple="true" interface="action-sheet"
[placeholder]="'addon.mod_glossary.categories' | translate" name="categories" [placeholder]="'addon.mod_glossary.categories' | translate" name="categories"
[interfaceOptions]="{header: 'addon.mod_glossary.categories' | translate}"> [interfaceOptions]="{header: 'addon.mod_glossary.categories' | translate}">
<ion-select-option *ngFor="let category of categories" [value]="category.id"> <ion-select-option *ngFor="let category of categories" [value]="category.id">
@ -39,11 +39,11 @@
</ion-select-option> </ion-select-option>
</ion-select> </ion-select>
</ion-item> </ion-item>
<ion-item> <ion-item *ngIf="showAliases">
<ion-label position="stacked"> <ion-label position="stacked">
{{ 'addon.mod_glossary.aliases' | translate }} {{ 'addon.mod_glossary.aliases' | translate }}
</ion-label> </ion-label>
<ion-textarea [(ngModel)]="options.aliases" rows="1" [core-auto-rows]="options.aliases" name="aliases"> <ion-textarea [(ngModel)]="data.aliases" rows="1" [core-auto-rows]="data.aliases" name="aliases">
</ion-textarea> </ion-textarea>
</ion-item> </ion-item>
<ion-item-divider> <ion-item-divider>
@ -51,7 +51,7 @@
<h2>{{ 'addon.mod_glossary.attachment' | translate }}</h2> <h2>{{ 'addon.mod_glossary.attachment' | translate }}</h2>
</ion-label> </ion-label>
</ion-item-divider> </ion-item-divider>
<core-attachments [files]="attachments" [component]="component" [componentId]="glossary.coursemodule" [allowOffline]="true" <core-attachments [files]="data.attachments" [component]="component" [componentId]="glossary.coursemodule" [allowOffline]="true"
[courseId]="courseId"> [courseId]="courseId">
</core-attachments> </core-attachments>
<ng-container *ngIf="glossary.usedynalink"> <ng-container *ngIf="glossary.usedynalink">
@ -62,19 +62,19 @@
</ion-item-divider> </ion-item-divider>
<ion-item class="ion-text-wrap"> <ion-item class="ion-text-wrap">
<ion-label>{{ 'addon.mod_glossary.entryusedynalink' | translate }}</ion-label> <ion-label>{{ 'addon.mod_glossary.entryusedynalink' | translate }}</ion-label>
<ion-toggle [(ngModel)]="options.usedynalink" name="usedynalink"></ion-toggle> <ion-toggle [(ngModel)]="data.usedynalink" name="usedynalink"></ion-toggle>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap"> <ion-item class="ion-text-wrap">
<ion-label>{{ 'addon.mod_glossary.casesensitive' | translate }}</ion-label> <ion-label>{{ 'addon.mod_glossary.casesensitive' | translate }}</ion-label>
<ion-toggle [disabled]="!options.usedynalink" [(ngModel)]="options.casesensitive" name="casesensitive"> <ion-toggle [disabled]="!data.usedynalink" [(ngModel)]="data.casesensitive" name="casesensitive">
</ion-toggle> </ion-toggle>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap"> <ion-item class="ion-text-wrap">
<ion-label>{{ 'addon.mod_glossary.fullmatch' | translate }}</ion-label> <ion-label>{{ 'addon.mod_glossary.fullmatch' | translate }}</ion-label>
<ion-toggle [disabled]="!options.usedynalink" [(ngModel)]="options.fullmatch" name="fullmatch"></ion-toggle> <ion-toggle [disabled]="!data.usedynalink" [(ngModel)]="data.fullmatch" name="fullmatch"></ion-toggle>
</ion-item> </ion-item>
</ng-container> </ng-container>
<ion-button class="ion-margin" expand="block" [disabled]="!entry.concept || !entry.definition" (click)="save()"> <ion-button class="ion-margin" expand="block" [disabled]="!data.concept || !data.definition" (click)="save()">
{{ 'core.save' | translate }} {{ 'core.save' | translate }}
</ion-button> </ion-button>
</form> </form>

View File

@ -12,16 +12,17 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // 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 { FormControl } from '@angular/forms';
import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { CoreError } from '@classes/errors/error'; import { CoreError } from '@classes/errors/error';
import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; import { CoreNetworkError } from '@classes/errors/network-error';
import { CoreSplitViewComponent } from '@components/split-view/split-view'; import { CoreSplitViewComponent } from '@components/split-view/split-view';
import { CoreFileUploader, CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader'; import { CoreFileUploader, CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader';
import { CanLeave } from '@guards/can-leave'; import { CanLeave } from '@guards/can-leave';
import { FileEntry } from '@ionic-native/file/ngx'; import { CoreFileEntry } from '@services/file-helper';
import { CoreNavigator } from '@services/navigator'; import { CoreNavigator } from '@services/navigator';
import { CoreNetwork } from '@services/network';
import { CoreSites } from '@services/sites'; import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreDomUtils } from '@services/utils/dom';
import { CoreTextUtils } from '@services/utils/text'; import { CoreTextUtils } from '@services/utils/text';
@ -29,15 +30,12 @@ import { CoreUtils } from '@services/utils/utils';
import { Translate } from '@singletons'; import { Translate } from '@singletons';
import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { CoreForms } from '@singletons/form'; import { CoreForms } from '@singletons/form';
import { AddonModGlossaryEntriesSource } from '../../classes/glossary-entries-source';
import { AddonModGlossaryEntriesSwipeManager } from '../../classes/glossary-entries-swipe-manager';
import { import {
AddonModGlossary, AddonModGlossary,
AddonModGlossaryCategory, AddonModGlossaryCategory,
AddonModGlossaryEntry,
AddonModGlossaryEntryOption, AddonModGlossaryEntryOption,
AddonModGlossaryGlossary, AddonModGlossaryGlossary,
AddonModGlossaryNewEntry,
AddonModGlossaryNewEntryWithFiles,
AddonModGlossaryProvider, AddonModGlossaryProvider,
} from '../../services/glossary'; } from '../../services/glossary';
import { AddonModGlossaryHelper } from '../../services/glossary-helper'; import { AddonModGlossaryHelper } from '../../services/glossary-helper';
@ -50,7 +48,7 @@ import { AddonModGlossaryOffline } from '../../services/glossary-offline';
selector: 'page-addon-mod-glossary-edit', selector: 'page-addon-mod-glossary-edit',
templateUrl: 'edit.html', templateUrl: 'edit.html',
}) })
export class AddonModGlossaryEditPage implements OnInit, OnDestroy, CanLeave { export class AddonModGlossaryEditPage implements OnInit, CanLeave {
@ViewChild('editFormEl') formElement?: ElementRef; @ViewChild('editFormEl') formElement?: ElementRef;
@ -59,32 +57,28 @@ export class AddonModGlossaryEditPage implements OnInit, OnDestroy, CanLeave {
courseId!: number; courseId!: number;
loaded = false; loaded = false;
glossary?: AddonModGlossaryGlossary; glossary?: AddonModGlossaryGlossary;
attachments: FileEntry[] = [];
definitionControl = new FormControl(); definitionControl = new FormControl();
categories: AddonModGlossaryCategory[] = []; categories: AddonModGlossaryCategory[] = [];
showAliases = true;
editorExtraParams: Record<string, unknown> = {}; editorExtraParams: Record<string, unknown> = {};
entry: AddonModGlossaryNewEntry = { handler!: AddonModGlossaryFormHandler;
data: AddonModGlossaryFormData = {
concept: '', concept: '',
definition: '', definition: '',
timecreated: 0, timecreated: 0,
}; attachments: [],
categories: [],
entries?: AddonModGlossaryEditEntriesSwipeManager;
options = {
categories: <string[]> [],
aliases: '', aliases: '',
usedynalink: false, usedynalink: false,
casesensitive: false, casesensitive: false,
fullmatch: false, fullmatch: false,
}; };
protected timecreated!: number; originalData?: AddonModGlossaryFormData;
protected concept = '';
protected syncId?: string; protected syncId?: string;
protected syncObserver?: CoreEventObserver; protected syncObserver?: CoreEventObserver;
protected isDestroyed = false; protected isDestroyed = false;
protected originalData?: AddonModGlossaryNewEntryWithFiles;
protected saved = false; protected saved = false;
constructor(protected route: ActivatedRoute, @Optional() protected splitView: CoreSplitViewComponent) {} constructor(protected route: ActivatedRoute, @Optional() protected splitView: CoreSplitViewComponent) {}
@ -94,22 +88,21 @@ export class AddonModGlossaryEditPage implements OnInit, OnDestroy, CanLeave {
*/ */
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
try { try {
const routeData = this.route.snapshot.data; 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.timecreated = CoreNavigator.getRequiredRouteNumberParam('timecreated');
this.concept = CoreNavigator.getRouteParam<string>('concept') || '';
this.editorExtraParams.timecreated = this.timecreated;
if (this.timecreated !== 0 && (routeData.swipeEnabled ?? true)) { if (entrySlug?.startsWith('new-')) {
const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource( const timecreated = Number(entrySlug.slice(4));
AddonModGlossaryEntriesSource, this.editorExtraParams.timecreated = timecreated;
[this.courseId, this.cmId, routeData.glossaryPathPrefix ?? ''], this.handler = new AddonModGlossaryOfflineFormHandler(this, timecreated);
); } else if (entrySlug) {
const { entry } = await AddonModGlossary.getEntry(Number(entrySlug));
this.entries = new AddonModGlossaryEditEntriesSwipeManager(source); this.editorExtraParams.timecreated = entry.timecreated;
this.handler = new AddonModGlossaryOnlineFormHandler(this, entry);
await this.entries.start(); } else {
this.handler = new AddonModGlossaryNewFormHandler(this);
} }
} catch (error) { } catch (error) {
CoreDomUtils.showErrorModal(error); CoreDomUtils.showErrorModal(error);
@ -122,13 +115,6 @@ export class AddonModGlossaryEditPage implements OnInit, OnDestroy, CanLeave {
this.fetchData(); this.fetchData();
} }
/**
* @inheritdoc
*/
ngOnDestroy(): void {
this.entries?.destroy();
}
/** /**
* Fetch required data. * Fetch required data.
* *
@ -138,13 +124,7 @@ export class AddonModGlossaryEditPage implements OnInit, OnDestroy, CanLeave {
try { try {
this.glossary = await AddonModGlossary.getGlossary(this.courseId, this.cmId); this.glossary = await AddonModGlossary.getGlossary(this.courseId, this.cmId);
if (this.timecreated > 0) { await this.handler.loadData(this.glossary);
await this.loadOfflineData();
}
this.categories = await AddonModGlossary.getAllCategories(this.glossary.id, {
cmId: this.cmId,
});
this.loaded = true; this.loaded = true;
} catch (error) { } catch (error) {
@ -154,64 +134,21 @@ export class AddonModGlossaryEditPage implements OnInit, OnDestroy, CanLeave {
} }
} }
/**
* Load offline data when editing an offline entry.
*
* @returns Promise resolved when done.
*/
protected async loadOfflineData(): Promise<void> {
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 || '';
this.entry.timecreated = entry.timecreated;
this.originalData = {
concept: this.entry.concept,
definition: this.entry.definition,
files: [],
timecreated: entry.timecreated,
};
if (entry.options) {
this.options.categories = (entry.options.categories && (<string> entry.options.categories).split(',')) || [];
this.options.aliases = <string> entry.options.aliases || '';
this.options.usedynalink = !!entry.options.usedynalink;
if (this.options.usedynalink) {
this.options.casesensitive = !!entry.options.casesensitive;
this.options.fullmatch = !!entry.options.fullmatch;
}
}
// Treat offline attachments if any.
if (entry.attachments?.offline) {
this.attachments = await AddonModGlossaryHelper.getStoredFiles(this.glossary.id, entry.concept, entry.timecreated);
this.originalData.files = this.attachments.slice();
}
this.definitionControl.setValue(this.entry.definition);
}
/** /**
* Reset the form data. * Reset the form data.
*/ */
protected resetForm(): void { protected resetForm(): void {
this.entry.concept = '';
this.entry.definition = '';
this.entry.timecreated = 0;
this.originalData = undefined; this.originalData = undefined;
this.options.categories = []; this.data.concept = '';
this.options.aliases = ''; this.data.definition = '';
this.options.usedynalink = false; this.data.timecreated = 0;
this.options.casesensitive = false; this.data.categories = [];
this.options.fullmatch = false; this.data.aliases = '';
this.attachments.length = 0; // Empty the array. this.data.usedynalink = false;
this.data.casesensitive = false;
this.data.fullmatch = false;
this.data.attachments.length = 0; // Empty the array.
this.definitionControl.setValue(''); this.definitionControl.setValue('');
} }
@ -222,7 +159,7 @@ export class AddonModGlossaryEditPage implements OnInit, OnDestroy, CanLeave {
* @param text The new text. * @param text The new text.
*/ */
onDefinitionChange(text: string): void { onDefinitionChange(text: string): void {
this.entry.definition = text; this.data.definition = text;
} }
/** /**
@ -235,13 +172,13 @@ export class AddonModGlossaryEditPage implements OnInit, OnDestroy, CanLeave {
return true; return true;
} }
if (AddonModGlossaryHelper.hasEntryDataChanged(this.entry, this.attachments, this.originalData)) { if (this.hasDataChanged()) {
// Show confirmation if some data has been modified. // Show confirmation if some data has been modified.
await CoreDomUtils.showConfirm(Translate.instant('core.confirmcanceledit')); await CoreDomUtils.showConfirm(Translate.instant('core.confirmcanceledit'));
} }
// Delete the local files from the tmp folder. // Delete the local files from the tmp folder.
CoreFileUploader.clearTmpFiles(this.attachments); CoreFileUploader.clearTmpFiles(this.data.attachments);
CoreForms.triggerFormCancelledEvent(this.formElement, CoreSites.getCurrentSiteId()); CoreForms.triggerFormCancelledEvent(this.formElement, CoreSites.getCurrentSiteId());
@ -252,114 +189,26 @@ export class AddonModGlossaryEditPage implements OnInit, OnDestroy, CanLeave {
* Save the entry. * Save the entry.
*/ */
async save(): Promise<void> { async save(): Promise<void> {
let definition = this.entry.definition; if (!this.data.concept || !this.data.definition) {
let entryId: number | undefined;
const timecreated = this.entry.timecreated || Date.now();
if (!this.entry.concept || !definition) {
CoreDomUtils.showErrorModal('addon.mod_glossary.fillfields', true); CoreDomUtils.showErrorModal('addon.mod_glossary.fillfields', true);
return; return;
} }
if (!this.glossary) {
return;
}
const modal = await CoreDomUtils.showModalLoading('core.sending', true); const modal = await CoreDomUtils.showModalLoading('core.sending', true);
definition = CoreTextUtils.formatHtmlLines(definition);
try { try {
if (!this.glossary) { const savedOnline = await this.handler.save(this.glossary);
return;
}
// Upload attachments first if any. this.saved = true;
const { saveOffline, attachmentsResult } = await this.uploadAttachments(timecreated);
const options: Record<string, AddonModGlossaryEntryOption> = { CoreForms.triggerFormSubmittedEvent(this.formElement, savedOnline, CoreSites.getCurrentSiteId());
aliases: this.options.aliases,
categories: this.options.categories.join(','),
};
if (this.glossary.usedynalink) { this.goBack();
options.usedynalink = this.options.usedynalink ? 1 : 0;
if (this.options.usedynalink) {
options.casesensitive = this.options.casesensitive ? 1 : 0;
options.fullmatch = this.options.fullmatch ? 1 : 0;
}
}
if (saveOffline) {
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, {
timeCreated: this.entry.timecreated,
cmId: this.cmId,
});
if (isUsed) {
// There's a entry with same name, reject with error message.
throw new CoreError(Translate.instant('addon.mod_glossary.errconceptalreadyexists'));
}
}
// Save entry in offline.
await AddonModGlossaryOffline.addNewEntry(
this.glossary.id,
this.entry.concept,
definition,
this.courseId,
options,
<CoreFileUploaderStoreFilesResult> attachmentsResult,
timecreated,
undefined,
undefined,
this.entry,
);
} else {
// 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.entry.concept,
definition,
this.courseId,
options,
attachmentsResult,
{
timeCreated: timecreated,
discardEntry: this.entry,
allowOffline: !this.attachments.length,
checkDuplicates: !this.glossary.allowduplicatedentries,
},
);
}
// Delete the local files from the tmp folder.
CoreFileUploader.clearTmpFiles(this.attachments);
if (entryId) {
// Data sent to server, delete stored files (if any).
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,
entryId: entryId,
}, CoreSites.getCurrentSiteId());
CoreForms.triggerFormSubmittedEvent(this.formElement, !!entryId, CoreSites.getCurrentSiteId());
if (this.splitView?.outletActivated) {
if (this.timecreated > 0) {
// Reload the data.
await this.loadOfflineData();
} else {
// Empty form.
this.resetForm();
}
} else {
this.saved = true;
CoreNavigator.back();
}
} catch (error) { } catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'addon.mod_glossary.cannoteditentry', true); CoreDomUtils.showErrorModalDefault(error, 'addon.mod_glossary.cannoteditentry', true);
} finally { } finally {
@ -368,49 +217,21 @@ export class AddonModGlossaryEditPage implements OnInit, OnDestroy, CanLeave {
} }
/** /**
* Upload entry attachments if any. * Check if the form data has changed.
* *
* @param timecreated Entry's timecreated. * @returns True if data has changed, false otherwise.
* @returns Promise resolved when done.
*/ */
protected async uploadAttachments( protected hasDataChanged(): boolean {
timecreated: number, if (!this.originalData || this.originalData.concept === undefined) {
): Promise<{saveOffline: boolean; attachmentsResult?: number | CoreFileUploaderStoreFilesResult}> { // There is no original data.
if (!this.attachments.length || !this.glossary) { return !!(this.data.definition || this.data.concept || this.data.attachments.length > 0);
return {
saveOffline: false,
};
} }
try { if (this.originalData.definition != this.data.definition || this.originalData.concept != this.data.concept) {
const attachmentsResult = await CoreFileUploader.uploadOrReuploadFiles( return true;
this.attachments,
AddonModGlossaryProvider.COMPONENT,
this.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(
this.glossary.id,
this.entry.concept,
timecreated,
this.attachments,
);
return {
saveOffline: true,
attachmentsResult,
};
} }
return CoreFileUploader.areFileListDifferent(this.data.attachments, this.originalData.attachments);
} }
/** /**
@ -427,15 +248,463 @@ export class AddonModGlossaryEditPage implements OnInit, OnDestroy, CanLeave {
} }
/** /**
* Helper to manage swiping within a collection of glossary entries. * Helper to manage form data.
*/ */
class AddonModGlossaryEditEntriesSwipeManager extends AddonModGlossaryEntriesSwipeManager { abstract class AddonModGlossaryFormHandler {
constructor(protected page: AddonModGlossaryEditPage) {}
/**
* Load form data.
*
* @param glossary Glossary.
*/
abstract loadData(glossary: AddonModGlossaryGlossary): Promise<void>;
/**
* Save form data.
*
* @param glossary Glossary.
* @returns Whether the form was saved online.
*/
abstract save(glossary: AddonModGlossaryGlossary): Promise<boolean>;
/**
* Load form categories.
*
* @param glossary Glossary.
*/
protected async loadCategories(glossary: AddonModGlossaryGlossary): Promise<void> {
this.page.categories = await AddonModGlossary.getAllCategories(glossary.id, {
cmId: this.page.cmId,
});
}
/**
* Upload attachments online.
*
* @param glossary Glossary.
* @returns Uploaded attachments item id.
*/
protected async uploadAttachments(glossary: AddonModGlossaryGlossary): Promise<number> {
const data = this.page.data;
const itemId = await CoreFileUploader.uploadOrReuploadFiles(
data.attachments,
AddonModGlossaryProvider.COMPONENT,
glossary.id,
);
return itemId;
}
/**
* Store attachments offline.
*
* @param glossary Glossary.
* @param timecreated Entry time created.
* @returns Storage result.
*/
protected async storeAttachments(
glossary: AddonModGlossaryGlossary,
timecreated: number,
): Promise<CoreFileUploaderStoreFilesResult> {
const data = this.page.data;
const result = await AddonModGlossaryHelper.storeFiles(
glossary.id,
data.concept,
timecreated,
data.attachments,
);
return result;
}
/**
* Make sure that the new entry won't create any duplicates.
*
* @param glossary Glossary.
*/
protected async checkDuplicates(glossary: AddonModGlossaryGlossary): Promise<void> {
if (glossary.allowduplicatedentries) {
return;
}
const data = this.page.data;
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'));
}
}
/**
* 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> = {};
if (this.page.showAliases) {
options.aliases = data.aliases;
}
if (this.page.categories.length > 0) {
options.categories = data.categories.join(',');
}
if (glossary.usedynalink) {
options.usedynalink = data.usedynalink ? 1 : 0;
if (data.usedynalink) {
options.casesensitive = data.casesensitive ? 1 : 0;
options.fullmatch = data.fullmatch ? 1 : 0;
}
}
return options;
}
}
/**
* 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 * @inheritdoc
*/ */
protected getSelectedItemPathFromRoute(route: ActivatedRouteSnapshot): string | null { async loadData(glossary: AddonModGlossaryGlossary): Promise<void> {
return `${this.getSource().GLOSSARY_PATH_PREFIX}edit/${route.params.timecreated}`; 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 as string)?.split(',') ?? []).map(id => Number(id));
data.aliases = entry.options.aliases as string ?? '';
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);
await this.loadCategories(glossary);
}
/**
* @inheritdoc
*/
async save(glossary: AddonModGlossaryGlossary): Promise<boolean> {
const originalData = this.page.data;
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);
}
if (originalData.concept !== data.concept) {
await AddonModGlossaryHelper.deleteStoredFiles(glossary.id, originalData.concept, 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.
*/
class AddonModGlossaryNewFormHandler extends AddonModGlossaryFormHandler {
/**
* @inheritdoc
*/
async loadData(glossary: AddonModGlossaryGlossary): Promise<void> {
await this.loadCategories(glossary);
}
/**
* @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;
}
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);
if (entryId) {
// Data sent to server, delete stored files (if any).
AddonModGlossaryHelper.deleteStoredFiles(glossary.id, data.concept, timecreated);
CoreEvents.trigger(CoreEvents.ACTIVITY_DATA_SENT, { module: 'glossary' });
}
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;
}
}
/**
* Helper to manage the form data for an online entry.
*/
class AddonModGlossaryOnlineFormHandler extends AddonModGlossaryFormHandler {
private entry: AddonModGlossaryEntry;
constructor(page: AddonModGlossaryEditPage, entry: AddonModGlossaryEntry) {
super(page);
this.entry = entry;
}
/**
* @inheritdoc
*/
async loadData(): Promise<void> {
const data = this.page.data;
data.concept = this.entry.concept;
data.definition = this.entry.definition || '';
data.timecreated = this.entry.timecreated;
data.usedynalink = this.entry.usedynalink;
if (data.usedynalink) {
data.casesensitive = this.entry.casesensitive;
data.fullmatch = this.entry.fullmatch;
}
// Treat offline attachments if any.
if (this.entry.attachments) {
data.attachments = this.entry.attachments;
}
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);
this.page.showAliases = false;
}
/**
* @inheritdoc
*/
async save(glossary: AddonModGlossaryGlossary): Promise<boolean> {
if (!CoreNetwork.isOnline()) {
throw new CoreNetworkError();
}
const data = this.page.data;
const options = this.getSaveOptions(glossary);
const definition = CoreTextUtils.formatHtmlLines(data.definition);
// Upload attachments, if any.
let attachmentsId: number | undefined = undefined;
if (data.attachments.length) {
attachmentsId = await this.uploadAttachments(glossary);
}
// Save entry data.
await AddonModGlossary.updateEntry(glossary.id, this.entry.id, data.concept, definition, options, attachmentsId);
// Delete the local files from the tmp folder.
CoreFileUploader.clearTmpFiles(data.attachments);
CoreEvents.trigger(CoreEvents.ACTIVITY_DATA_SENT, { module: 'glossary' });
return true;
}
}
/**
* Form data.
*/
type AddonModGlossaryFormData = {
concept: string;
definition: string;
timecreated: number;
attachments: CoreFileEntry[];
categories: number[];
aliases: string;
usedynalink: boolean;
casesensitive: boolean;
fullmatch: boolean;
};

View File

@ -18,6 +18,12 @@
<core-loading [hideUntil]="loaded"> <core-loading [hideUntil]="loaded">
<ng-container *ngIf="entry && 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"> <ion-item class="ion-text-wrap" *ngIf="showAuthor">
<core-user-avatar [user]="entry" slot="start"></core-user-avatar> <core-user-avatar [user]="entry" slot="start"></core-user-avatar>
<ion-label> <ion-label>
@ -26,9 +32,9 @@
[courseId]="courseId"> [courseId]="courseId">
</core-format-text> </core-format-text>
</h2> </h2>
<p>{{ entry.userfullname }}</p> <p *ngIf="onlineEntry">{{ onlineEntry.userfullname }}</p>
</ion-label> </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>
<ion-item class="ion-text-wrap" *ngIf="!showAuthor"> <ion-item class="ion-text-wrap" *ngIf="!showAuthor">
<ion-label> <ion-label>
@ -37,7 +43,7 @@
</core-format-text> </core-format-text>
</p> </p>
</ion-label> </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>
<ion-item class="ion-text-wrap"> <ion-item class="ion-text-wrap">
<ion-label> <ion-label>
@ -46,32 +52,53 @@
</core-format-text> </core-format-text>
</ion-label> </ion-label>
</ion-item> </ion-item>
<div *ngIf="entry.attachment"> <ion-item *ngIf="canDelete || canEdit">
<core-file *ngFor="let file of entry.attachments" [file]="file" [component]="component" [componentId]="componentId"> <div slot="end">
<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-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>
</ion-item>
<div *ngIf="onlineEntry && onlineEntry.attachment">
<core-file *ngFor="let file of onlineEntry.attachments" [file]="file" [component]="component" [componentId]="componentId">
</core-file> </core-file>
</div> </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>
<div *ngIf="offlineEntry && offlineEntryFiles">
<core-local-file *ngFor="let file of offlineEntryFiles" [file]="file">
</core-local-file>
</div>
<ion-item class="ion-text-wrap" *ngIf="onlineEntry && tagsEnabled && entry && onlineEntry.tags && onlineEntry.tags.length > 0">
<ion-label> <ion-label>
<div slot="start">{{ 'core.tag.tags' | translate }}:</div> <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-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="!entry.approved"> <ion-item class="ion-text-wrap" *ngIf="onlineEntry && !onlineEntry.approved">
<ion-label> <ion-label>
<p><em>{{ 'addon.mod_glossary.entrypendingapproval' | translate }}</em></p> <p><em>{{ 'addon.mod_glossary.entrypendingapproval' | translate }}</em></p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<core-comments *ngIf="glossary && glossary.allowcomments && entry && entry.id > 0 && commentsEnabled" contextLevel="module" <core-comments *ngIf="glossary && glossary.allowcomments && onlineEntry && onlineEntry.id > 0 && commentsEnabled"
[instanceId]="glossary.coursemodule" component="mod_glossary" [itemId]="entry.id" area="glossary_entry" contextLevel="module" [instanceId]="glossary.coursemodule" component="mod_glossary" [itemId]="onlineEntry.id"
[courseId]="glossary.course" [showItem]="true"> area="glossary_entry" [courseId]="glossary.course" [showItem]="true">
</core-comments> </core-comments>
<core-rating-rate *ngIf="glossary && ratingInfo" [ratingInfo]="ratingInfo" contextLevel="module" <core-rating-rate *ngIf="glossary && ratingInfo && onlineEntry" [ratingInfo]="ratingInfo" contextLevel="module"
[instanceId]="glossary.coursemodule" [itemId]="entry.id" [itemSetId]="0" [courseId]="glossary.course" [instanceId]="glossary.coursemodule" [itemId]="onlineEntry.id" [itemSetId]="0" [courseId]="glossary.course"
[aggregateMethod]="glossary.assessed" [scaleId]="glossary.scale" [userId]="entry.userid" (onUpdate)="ratingUpdated()"> [aggregateMethod]="glossary.assessed" [scaleId]="glossary.scale" [userId]="entry.userid" (onUpdate)="ratingUpdated()">
</core-rating-rate> </core-rating-rate>
<core-rating-aggregate *ngIf="glossary && ratingInfo" [ratingInfo]="ratingInfo" contextLevel="module" <core-rating-aggregate *ngIf="glossary && ratingInfo && onlineEntry" [ratingInfo]="ratingInfo" contextLevel="module"
[instanceId]="glossary.coursemodule" [itemId]="entry.id" [courseId]="glossary.course" [aggregateMethod]="glossary.assessed" [instanceId]="glossary.coursemodule" [itemId]="onlineEntry.id" [courseId]="glossary.course"
[scaleId]="glossary.scale"> [aggregateMethod]="glossary.assessed" [scaleId]="glossary.scale">
</core-rating-aggregate> </core-rating-aggregate>
</ng-container> </ng-container>

View File

@ -12,24 +12,32 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { AddonModGlossaryHelper } from '@addons/mod/glossary/services/glossary-helper';
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 { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router';
import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; 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 { CoreCommentsCommentsComponent } from '@features/comments/components/comments/comments';
import { CoreComments } from '@features/comments/services/comments'; import { CoreComments } from '@features/comments/services/comments';
import { CoreRatingInfo } from '@features/rating/services/rating'; import { CoreRatingInfo } from '@features/rating/services/rating';
import { CoreTag } from '@features/tag/services/tag'; import { CoreTag } from '@features/tag/services/tag';
import { FileEntry } from '@ionic-native/file/ngx';
import { IonRefresher } from '@ionic/angular'; import { IonRefresher } from '@ionic/angular';
import { CoreNavigator } from '@services/navigator'; import { CoreNavigator } from '@services/navigator';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreNetwork } from '@services/network';
import { CoreDomUtils, ToastDuration } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
import { AddonModGlossaryEntriesSource } from '../../classes/glossary-entries-source'; import { Translate } from '@singletons';
import { AddonModGlossaryEntriesSwipeManager } from '../../classes/glossary-entries-swipe-manager'; import { CoreEventObserver, CoreEvents } from '@singletons/events';
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';
/** /**
@ -45,62 +53,90 @@ export class AddonModGlossaryEntryPage implements OnInit, OnDestroy {
component = AddonModGlossaryProvider.COMPONENT; component = AddonModGlossaryProvider.COMPONENT;
componentId?: number; componentId?: number;
entry?: AddonModGlossaryEntry; onlineEntry?: AddonModGlossaryEntry;
entries?: AddonModGlossaryEntryEntriesSwipeManager; offlineEntry?: AddonModGlossaryOfflineEntry;
offlineEntryFiles?: FileEntry[];
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;
commentsEnabled = false; commentsEnabled = false;
courseId!: number; courseId!: number;
cmId?: number; cmId!: number;
protected entryId!: number; constructor(@Optional() protected splitView: CoreSplitViewComponent, protected route: ActivatedRoute) {}
constructor(protected route: ActivatedRoute) {} get entry(): AddonModGlossaryEntry | AddonModGlossaryOfflineEntry | undefined {
return this.onlineEntry ?? this.offlineEntry;
}
/** /**
* @inheritdoc * @inheritdoc
*/ */
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
let onlineEntryId: number | null = null;
let offlineEntryTimeCreated: number | null = null;
try { try {
const routeData = this.route.snapshot.data;
this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId');
this.entryId = CoreNavigator.getRequiredRouteNumberParam('entryId');
this.tagsEnabled = CoreTag.areTagsAvailableInSite(); this.tagsEnabled = CoreTag.areTagsAvailableInSite();
this.commentsEnabled = !CoreComments.areCommentsDisabledInSite(); this.commentsEnabled = !CoreComments.areCommentsDisabledInSite();
this.cmId = CoreNavigator.getRequiredRouteNumberParam('cmId');
if (routeData.swipeEnabled ?? true) { const entrySlug = CoreNavigator.getRequiredRouteParam<string>('entrySlug');
this.cmId = CoreNavigator.getRequiredRouteNumberParam('cmId'); const routeData = this.route.snapshot.data;
const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource( const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource(
AddonModGlossaryEntriesSource, AddonModGlossaryEntriesSource,
[this.courseId, this.cmId, routeData.glossaryPathPrefix ?? ''], [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-')) {
offlineEntryTimeCreated = Number(entrySlug.slice(4));
} else { } else {
this.cmId = CoreNavigator.getRouteNumberParam('cmId'); onlineEntryId = Number(entrySlug);
} }
} catch (error) { } catch (error) {
CoreDomUtils.showErrorModal(error); CoreDomUtils.showErrorModal(error);
CoreNavigator.back(); CoreNavigator.back();
return; return;
} }
try { this.entryUpdatedObserver = CoreEvents.on(GLOSSARY_ENTRY_UPDATED, data => {
await this.fetchEntry(); if (data.glossaryId !== this.glossary?.id) {
if (!this.glossary || !this.componentId) {
return; return;
} }
await CoreUtils.ignoreErrors(AddonModGlossary.logEntryView(this.entryId, this.componentId, this.glossary.name)); if (
(this.onlineEntry && this.onlineEntry.id === data.entryId) ||
(this.offlineEntry && this.offlineEntry.timecreated === data.timecreated)
) {
this.doRefresh();
}
});
try {
if (onlineEntryId) {
await this.loadOnlineEntry(onlineEntryId);
if (!this.glossary || !this.componentId) {
return;
}
await CoreUtils.ignoreErrors(AddonModGlossary.logEntryView(onlineEntryId, this.componentId, this.glossary?.name));
} else if (offlineEntryTimeCreated) {
await this.loadOfflineEntry(offlineEntryTimeCreated);
}
} finally { } finally {
this.loaded = true; this.loaded = true;
} }
@ -110,7 +146,66 @@ export class AddonModGlossaryEntryPage implements OnInit, OnDestroy {
* @inheritdoc * @inheritdoc
*/ */
ngOnDestroy(): void { ngOnDestroy(): void {
this.entries?.destroy(); this.entries.destroy();
this.entryUpdatedObserver?.off();
}
/**
* Edit entry.
*/
async editEntry(): Promise<void> {
await CoreNavigator.navigate('./edit');
}
/**
* Delete entry.
*/
async deleteEntry(): Promise<void> {
const glossaryId = this.glossary?.id;
const cancelled = await CoreUtils.promiseFails(
CoreDomUtils.showConfirm(Translate.instant('addon.mod_glossary.areyousuredelete')),
);
if (!glossaryId || cancelled) {
return;
}
const modal = await CoreDomUtils.showModalLoading();
try {
if (this.onlineEntry) {
const entryId = this.onlineEntry.id;
await AddonModGlossary.deleteEntry(glossaryId, entryId);
await Promise.all([
CoreUtils.ignoreErrors(AddonModGlossary.invalidateEntry(entryId)),
CoreUtils.ignoreErrors(AddonModGlossary.invalidateEntriesByLetter(glossaryId)),
CoreUtils.ignoreErrors(AddonModGlossary.invalidateEntriesByAuthor(glossaryId)),
CoreUtils.ignoreErrors(AddonModGlossary.invalidateEntriesByCategory(glossaryId)),
CoreUtils.ignoreErrors(AddonModGlossary.invalidateEntriesByDate(glossaryId, 'CREATION')),
CoreUtils.ignoreErrors(AddonModGlossary.invalidateEntriesByDate(glossaryId, 'UPDATE')),
CoreUtils.ignoreErrors(this.entries.getSource().invalidateCache(false)),
]);
} else if (this.offlineEntry) {
const concept = this.offlineEntry.concept;
const timecreated = this.offlineEntry.timecreated;
await AddonModGlossaryOffline.deleteOfflineEntry(glossaryId, timecreated);
await AddonModGlossaryHelper.deleteStoredFiles(glossaryId, concept, timecreated);
}
CoreDomUtils.showToast('addon.mod_glossary.entrydeleted', true, ToastDuration.LONG);
if (this.splitView?.outletActivated) {
await CoreNavigator.navigate('../');
} else {
await CoreNavigator.back();
}
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'addon.mod_glossary.errordeleting', true);
} finally {
modal.dismiss();
}
} }
/** /**
@ -120,65 +215,110 @@ export class AddonModGlossaryEntryPage implements OnInit, OnDestroy {
* @returns Promise resolved when done. * @returns Promise resolved when done.
*/ */
async doRefresh(refresher?: IonRefresher): Promise<void> { async doRefresh(refresher?: IonRefresher): Promise<void> {
if (this.glossary?.allowcomments && this.entry && this.entry.id > 0 && this.commentsEnabled && this.comments) { if (this.onlineEntry && this.glossary?.allowcomments && this.onlineEntry.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. // Refresh comments asynchronously (without blocking the current promise).
CoreUtils.ignoreErrors(this.comments.doRefresh()); CoreUtils.ignoreErrors(this.comments.doRefresh());
} }
try { 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 { } finally {
refresher?.complete(); refresher?.complete();
} }
} }
/** /**
* Convenience function to get the glossary entry. * Load online entry data.
*
* @returns Promise resolved when done.
*/ */
protected async fetchEntry(): Promise<void> { protected async loadOnlineEntry(entryId: number): Promise<void> {
try { try {
const result = await AddonModGlossary.getEntry(this.entryId); const result = await AddonModGlossary.getEntry(entryId);
const canDeleteEntries = CoreNetwork.isOnline() && await AddonModGlossary.canDeleteEntries();
const canUpdateEntries = CoreNetwork.isOnline() && await AddonModGlossary.canUpdateEntries();
this.entry = result.entry; this.onlineEntry = result.entry;
this.ratingInfo = result.ratinginfo; this.ratingInfo = result.ratinginfo;
this.canDelete = canDeleteEntries && !!result.permissions?.candelete;
this.canEdit = canUpdateEntries && !!result.permissions?.canupdate;
if (this.glossary) { await this.loadGlossary();
// 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;
}
} catch (error) { } catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'addon.mod_glossary.errorloadingentry', true); CoreDomUtils.showErrorModalDefault(error, 'addon.mod_glossary.errorloadingentry', true);
} }
} }
/**
* Load offline entry data.
*
* @param timecreated Entry Timecreated.
*/
protected async loadOfflineEntry(timecreated: number): Promise<void> {
try {
const glossary = await this.loadGlossary();
this.offlineEntry = await AddonModGlossaryOffline.getOfflineEntry(glossary.id, timecreated);
this.offlineEntryFiles = this.offlineEntry.attachments && this.offlineEntry.attachments.offline > 0
? await AddonModGlossaryHelper.getStoredFiles(
glossary.id,
this.offlineEntry.concept,
timecreated,
)
: undefined;
this.canEdit = true;
this.canDelete = true;
} 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. * Function called when rating is updated online.
*/ */
ratingUpdated(): void { ratingUpdated(): void {
AddonModGlossary.invalidateEntry(this.entryId); if (!this.onlineEntry) {
return;
}
AddonModGlossary.invalidateEntry(this.onlineEntry.id);
} }
} }
@ -186,13 +326,14 @@ export class AddonModGlossaryEntryPage implements OnInit, OnDestroy {
/** /**
* Helper to manage swiping within a collection of glossary entries. * Helper to manage swiping within a collection of glossary entries.
*/ */
class AddonModGlossaryEntryEntriesSwipeManager extends AddonModGlossaryEntriesSwipeManager { class AddonModGlossaryEntryEntriesSwipeManager
extends CoreSwipeNavigationItemsManager<AddonModGlossaryEntryItem, AddonModGlossaryEntriesSource> {
/** /**
* @inheritdoc * @inheritdoc
*/ */
protected getSelectedItemPathFromRoute(route: ActivatedRouteSnapshot): string | null { 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

@ -18,7 +18,6 @@ import { CoreFileUploader, CoreFileUploaderStoreFilesResult } from '@features/fi
import { CoreFile } from '@services/file'; import { CoreFile } from '@services/file';
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
import { AddonModGlossaryOffline } from './glossary-offline'; import { AddonModGlossaryOffline } from './glossary-offline';
import { AddonModGlossaryNewEntry, AddonModGlossaryNewEntryWithFiles } from './glossary';
import { makeSingleton } from '@singletons'; import { makeSingleton } from '@singletons';
import { CoreFileEntry } from '@services/file-helper'; import { CoreFileEntry } from '@services/file-helper';
@ -58,31 +57,6 @@ export class AddonModGlossaryHelperProvider {
return CoreFileUploader.getStoredFiles(folderPath); return CoreFileUploader.getStoredFiles(folderPath);
} }
/**
* Check if the data of an entry has changed.
*
* @param entry Current data.
* @param files Files attached.
* @param original Original content.
* @returns True if data has changed, false otherwise.
*/
hasEntryDataChanged(
entry: AddonModGlossaryNewEntry,
files: CoreFileEntry[],
original?: AddonModGlossaryNewEntryWithFiles,
): boolean {
if (!original || original.concept === undefined) {
// There is no original data.
return !!(entry.definition || entry.concept || files.length > 0);
}
if (original.definition != entry.definition || original.concept != entry.concept) {
return true;
}
return CoreFileUploader.areFileListDifferent(files, original.files);
}
/** /**
* Given a list of files (either online files or local files), store the local files in a local folder * Given a list of files (either online files or local files), store the local files in a local folder
* to be submitted later. * to be submitted later.

View File

@ -17,11 +17,11 @@ import { CoreFileUploaderStoreFilesResult } from '@features/fileuploader/service
import { CoreFile } from '@services/file'; import { CoreFile } from '@services/file';
import { CoreSites } from '@services/sites'; import { CoreSites } from '@services/sites';
import { CoreTextUtils } from '@services/utils/text'; import { CoreTextUtils } from '@services/utils/text';
import { CoreUtils } from '@services/utils/utils';
import { makeSingleton } from '@singletons'; import { makeSingleton } from '@singletons';
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 { AddonModGlossaryDiscardedEntry, AddonModGlossaryEntryOption } 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.
@ -30,33 +30,33 @@ import { AddonModGlossaryDiscardedEntry, AddonModGlossaryEntryOption } from './g
export class AddonModGlossaryOfflineProvider { export class AddonModGlossaryOfflineProvider {
/** /**
* Delete a new 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 deleteNewEntry(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: timeCreated,
}; };
await site.getDb().deleteRecords(OFFLINE_ENTRIES_TABLE_NAME, conditions); await site.getDb().deleteRecords(OFFLINE_ENTRIES_TABLE_NAME, conditions);
CoreEvents.trigger(GLOSSARY_ENTRY_DELETED, { glossaryId, timecreated });
} }
/** /**
* Get all the stored new entries from all the glossaries. * Get all the stored offline entries from all the glossaries.
* *
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @returns Promise resolved with entries. * @returns Promise resolved with entries.
*/ */
async getAllNewEntries(siteId?: string): Promise<AddonModGlossaryOfflineEntry[]> { async getAllOfflineEntries(siteId?: string): Promise<AddonModGlossaryOfflineEntry[]> {
const site = await CoreSites.getSite(siteId); const site = await CoreSites.getSite(siteId);
const records = await site.getDb().getRecords<AddonModGlossaryOfflineEntryDBRecord>(OFFLINE_ENTRIES_TABLE_NAME); const records = await site.getDb().getRecords<AddonModGlossaryOfflineEntryDBRecord>(OFFLINE_ENTRIES_TABLE_NAME);
@ -65,17 +65,15 @@ export class AddonModGlossaryOfflineProvider {
} }
/** /**
* Get a stored new 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 getNewEntry( async getOfflineEntry(
glossaryId: number, glossaryId: number,
concept: string,
timeCreated: number, timeCreated: number,
siteId?: string, siteId?: string,
): Promise<AddonModGlossaryOfflineEntry> { ): Promise<AddonModGlossaryOfflineEntry> {
@ -83,7 +81,6 @@ export class AddonModGlossaryOfflineProvider {
const conditions: Partial<AddonModGlossaryOfflineEntryDBRecord> = { const conditions: Partial<AddonModGlossaryOfflineEntryDBRecord> = {
glossaryid: glossaryId, glossaryid: glossaryId,
concept: concept,
timecreated: timeCreated, timecreated: timeCreated,
}; };
@ -100,7 +97,7 @@ export class AddonModGlossaryOfflineProvider {
* @param userId User the entries belong to. If not defined, current user in site. * @param userId User the entries belong to. If not defined, current user in site.
* @returns Promise resolved with entries. * @returns Promise resolved with entries.
*/ */
async getGlossaryNewEntries(glossaryId: number, siteId?: string, userId?: number): Promise<AddonModGlossaryOfflineEntry[]> { async getGlossaryOfflineEntries(glossaryId: number, siteId?: string, userId?: number): Promise<AddonModGlossaryOfflineEntry[]> {
const site = await CoreSites.getSite(siteId); const site = await CoreSites.getSite(siteId);
const conditions: Partial<AddonModGlossaryOfflineEntryDBRecord> = { const conditions: Partial<AddonModGlossaryOfflineEntryDBRecord> = {
@ -143,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.getNewEntry(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;
@ -151,31 +148,29 @@ export class AddonModGlossaryOfflineProvider {
} }
/** /**
* Save a new entry to be sent later. * Save an offline entry to be sent later.
* *
* @param glossaryId Glossary ID. * @param glossaryId Glossary ID.
* @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 addNewEntry( async addOfflineEntry(
glossaryId: number, glossaryId: number,
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);
@ -188,19 +183,52 @@ export class AddonModGlossaryOfflineProvider {
options: JSON.stringify(options || {}), options: JSON.stringify(options || {}),
attachments: JSON.stringify(attachments), attachments: JSON.stringify(attachments),
userid: userId || site.getUserId(), userid: userId || site.getUserId(),
timecreated: timeCreated || Date.now(), timecreated,
}; };
// If editing an offline entry, delete previous first.
if (discardEntry) {
await this.deleteNewEntry(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);
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.
* *
@ -218,7 +246,7 @@ export class AddonModGlossaryOfflineProvider {
} }
/** /**
* Get the path to the folder where to store files for a new offline entry. * Get the path to the folder where to store files for an offline entry.
* *
* @param glossaryId Glossary ID. * @param glossaryId Glossary ID.
* @param concept The name of the entry. * @param concept The name of the entry.

View File

@ -31,14 +31,14 @@ import { AddonModGlossaryOffline, AddonModGlossaryOfflineEntry } from './glossar
import { CoreFileUploader } from '@features/fileuploader/services/fileuploader'; import { CoreFileUploader } from '@features/fileuploader/services/fileuploader';
import { CoreFileEntry } from '@services/file-helper'; import { CoreFileEntry } from '@services/file-helper';
export const GLOSSARY_AUTO_SYNCED = 'addon_mod_glossary_auto_synced';
/** /**
* Service to sync glossaries. * Service to sync glossaries.
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AddonModGlossarySyncProvider extends CoreCourseActivitySyncBaseProvider<AddonModGlossarySyncResult> { export class AddonModGlossarySyncProvider extends CoreCourseActivitySyncBaseProvider<AddonModGlossarySyncResult> {
static readonly AUTO_SYNCED = 'addon_mod_glossary_autom_synced';
protected componentTranslatableString = 'glossary'; protected componentTranslatableString = 'glossary';
constructor() { constructor() {
@ -50,10 +50,9 @@ export class AddonModGlossarySyncProvider extends CoreCourseActivitySyncBaseProv
* *
* @param siteId Site ID to sync. If not defined, sync all sites. * @param siteId Site ID to sync. If not defined, sync all sites.
* @param force Wether to force sync not depending on last execution. * @param force Wether to force sync not depending on last execution.
* @returns Promise resolved if sync is successful, rejected if sync fails.
*/ */
syncAllGlossaries(siteId?: string, force?: boolean): Promise<void> { async syncAllGlossaries(siteId?: string, force?: boolean): Promise<void> {
return this.syncOnSites('all glossaries', (siteId) => this.syncAllGlossariesFunc(!!force, siteId), siteId); await this.syncOnSites('all glossaries', (siteId) => this.syncAllGlossariesFunc(!!force, siteId), siteId);
} }
/** /**
@ -61,7 +60,6 @@ export class AddonModGlossarySyncProvider extends CoreCourseActivitySyncBaseProv
* *
* @param force Wether to force sync not depending on last execution. * @param force Wether to force sync not depending on last execution.
* @param siteId Site ID to sync. * @param siteId Site ID to sync.
* @returns Promise resolved if sync is successful, rejected if sync fails.
*/ */
protected async syncAllGlossariesFunc(force: boolean, siteId: string): Promise<void> { protected async syncAllGlossariesFunc(force: boolean, siteId: string): Promise<void> {
siteId = siteId || CoreSites.getCurrentSiteId(); siteId = siteId || CoreSites.getCurrentSiteId();
@ -73,14 +71,13 @@ export class AddonModGlossarySyncProvider extends CoreCourseActivitySyncBaseProv
} }
/** /**
* Sync entried of all glossaries on a site. * Sync entries of all glossaries on a site.
* *
* @param force Wether to force sync not depending on last execution. * @param force Wether to force sync not depending on last execution.
* @param siteId Site ID to sync. * @param siteId Site ID to sync.
* @returns Promise resolved if sync is successful, rejected if sync fails.
*/ */
protected async syncAllGlossariesEntries(force: boolean, siteId: string): Promise<void> { protected async syncAllGlossariesEntries(force: boolean, siteId: string): Promise<void> {
const entries = await AddonModGlossaryOffline.getAllNewEntries(siteId); const entries = await AddonModGlossaryOffline.getAllOfflineEntries(siteId);
// Do not sync same glossary twice. // Do not sync same glossary twice.
const treated: Record<number, boolean> = {}; const treated: Record<number, boolean> = {};
@ -98,7 +95,7 @@ export class AddonModGlossarySyncProvider extends CoreCourseActivitySyncBaseProv
if (result?.updated) { if (result?.updated) {
// Sync successful, send event. // Sync successful, send event.
CoreEvents.trigger(AddonModGlossarySyncProvider.AUTO_SYNCED, { CoreEvents.trigger(GLOSSARY_AUTO_SYNCED, {
glossaryId: entry.glossaryid, glossaryId: entry.glossaryid,
userId: entry.userid, userId: entry.userid,
warnings: result.warnings, warnings: result.warnings,
@ -180,7 +177,7 @@ export class AddonModGlossarySyncProvider extends CoreCourseActivitySyncBaseProv
// Get offline responses to be sent. // Get offline responses to be sent.
const entries = await CoreUtils.ignoreErrors( const entries = await CoreUtils.ignoreErrors(
AddonModGlossaryOffline.getGlossaryNewEntries(glossaryId, siteId, userId), AddonModGlossaryOffline.getGlossaryOfflineEntries(glossaryId, siteId, userId),
<AddonModGlossaryOfflineEntry[]> [], <AddonModGlossaryOfflineEntry[]> [],
); );
@ -285,11 +282,10 @@ export class AddonModGlossarySyncProvider extends CoreCourseActivitySyncBaseProv
* @param concept Glossary entry concept. * @param concept Glossary entry concept.
* @param timeCreated Time to allow duplicated entries. * @param timeCreated Time to allow duplicated entries.
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @returns Promise resolved when deleted.
*/ */
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.deleteNewEntry(glossaryId, concept, timeCreated, siteId), AddonModGlossaryOffline.deleteOfflineEntry(glossaryId, timeCreated, siteId),
AddonModGlossaryHelper.deleteStoredFiles(glossaryId, concept, timeCreated, siteId), AddonModGlossaryHelper.deleteStoredFiles(glossaryId, concept, timeCreated, siteId),
]); ]);
} }
@ -341,15 +337,28 @@ export class AddonModGlossarySyncProvider extends CoreCourseActivitySyncBaseProv
export const AddonModGlossarySync = makeSingleton(AddonModGlossarySyncProvider); export const AddonModGlossarySync = makeSingleton(AddonModGlossarySyncProvider);
declare module '@singletons/events' {
/**
* Augment CoreEventsData interface with events specific to this service.
*
* @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation
*/
export interface CoreEventsData {
[GLOSSARY_AUTO_SYNCED]: AddonModGlossaryAutoSyncedData;
}
}
/** /**
* Data returned by a glossary sync. * Data returned by a glossary sync.
*/ */
export type AddonModGlossarySyncResult = CoreSyncResult; export type AddonModGlossarySyncResult = CoreSyncResult;
/** /**
* Data passed to AUTO_SYNCED event. * Data passed to GLOSSARY_AUTO_SYNCED event.
*/ */
export type AddonModGlossaryAutoSyncData = { export type AddonModGlossaryAutoSyncedData = {
glossaryId: number; glossaryId: number;
userId: number; userId: number;
warnings: string[]; warnings: string[];

View File

@ -25,12 +25,13 @@ import { CoreSites, CoreSitesCommonWSOptions, CoreSitesReadingStrategy } from '@
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
import { CoreWSExternalFile, CoreWSExternalWarning } from '@services/ws'; import { CoreWSExternalFile, CoreWSExternalWarning } from '@services/ws';
import { makeSingleton, Translate } from '@singletons'; import { makeSingleton, Translate } from '@singletons';
import { CoreEvents } from '@singletons/events';
import { AddonModGlossaryEntryDBRecord, ENTRIES_TABLE_NAME } from './database/glossary'; import { AddonModGlossaryEntryDBRecord, ENTRIES_TABLE_NAME } from './database/glossary';
import { AddonModGlossaryOffline } from './glossary-offline'; import { AddonModGlossaryOffline } from './glossary-offline';
import { AddonModGlossaryAutoSyncData, AddonModGlossarySyncProvider } from './glossary-sync';
import { CoreFileEntry } from '@services/file-helper';
const ROOT_CACHE_KEY = 'mmaModGlossary:'; 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';
/** /**
* Service that provides some features for glossaries. * Service that provides some features for glossaries.
@ -41,10 +42,9 @@ export class AddonModGlossaryProvider {
static readonly COMPONENT = 'mmaModGlossary'; static readonly COMPONENT = 'mmaModGlossary';
static readonly LIMIT_ENTRIES = 25; static readonly LIMIT_ENTRIES = 25;
static readonly LIMIT_CATEGORIES = 10; static readonly LIMIT_CATEGORIES = 10;
static readonly SHOW_ALL_CATEGORIES = 0;
static readonly SHOW_NOT_CATEGORISED = -1;
static readonly ADD_ENTRY_EVENT = 'addon_mod_glossary_add_entry'; private static readonly SHOW_ALL_CATEGORIES = 0;
private static readonly ROOT_CACHE_KEY = 'mmaModGlossary:';
/** /**
* Get the course glossary cache key. * Get the course glossary cache key.
@ -53,7 +53,7 @@ export class AddonModGlossaryProvider {
* @returns Cache key. * @returns Cache key.
*/ */
protected getCourseGlossariesCacheKey(courseId: number): string { protected getCourseGlossariesCacheKey(courseId: number): string {
return ROOT_CACHE_KEY + 'courseGlossaries:' + courseId; return `${AddonModGlossaryProvider.ROOT_CACHE_KEY}courseGlossaries:${courseId}`;
} }
/** /**
@ -90,7 +90,6 @@ export class AddonModGlossaryProvider {
* *
* @param courseId Course Id. * @param courseId Course Id.
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @returns Resolved when data is invalidated.
*/ */
async invalidateCourseGlossaries(courseId: number, siteId?: string): Promise<void> { async invalidateCourseGlossaries(courseId: number, siteId?: string): Promise<void> {
const site = await CoreSites.getSite(siteId); const site = await CoreSites.getSite(siteId);
@ -104,44 +103,35 @@ export class AddonModGlossaryProvider {
* Get the entries by author cache key. * Get the entries by author cache key.
* *
* @param glossaryId Glossary Id. * @param glossaryId Glossary Id.
* @param letter First letter of firstname or lastname, or either keywords: ALL or SPECIAL.
* @param field Search and order using: FIRSTNAME or LASTNAME
* @param sort The direction of the order: ASC or DESC
* @returns Cache key. * @returns Cache key.
*/ */
protected getEntriesByAuthorCacheKey(glossaryId: number, letter: string, field: string, sort: string): string { protected getEntriesByAuthorCacheKey(glossaryId: number): string {
return ROOT_CACHE_KEY + 'entriesByAuthor:' + glossaryId + ':' + letter + ':' + field + ':' + sort; return `${AddonModGlossaryProvider.ROOT_CACHE_KEY}entriesByAuthor:${glossaryId}:ALL:LASTNAME:ASC`;
} }
/** /**
* Get entries by author. * Get entries by author.
* *
* @param glossaryId Glossary Id. * @param glossaryId Glossary Id.
* @param letter First letter of firstname or lastname, or either keywords: ALL or SPECIAL.
* @param field Search and order using: FIRSTNAME or LASTNAME
* @param sort The direction of the order: ASC or DESC
* @param options Other options. * @param options Other options.
* @returns Resolved with the entries. * @returns Resolved with the entries.
*/ */
async getEntriesByAuthor( async getEntriesByAuthor(
glossaryId: number, glossaryId: number,
letter: string,
field: string,
sort: string,
options: AddonModGlossaryGetEntriesOptions = {}, options: AddonModGlossaryGetEntriesOptions = {},
): Promise<AddonModGlossaryGetEntriesWSResponse> { ): Promise<AddonModGlossaryGetEntriesWSResponse> {
const site = await CoreSites.getSite(options.siteId); const site = await CoreSites.getSite(options.siteId);
const params: AddonModGlossaryGetEntriesByAuthorWSParams = { const params: AddonModGlossaryGetEntriesByAuthorWSParams = {
id: glossaryId, id: glossaryId,
letter: letter, letter: 'ALL',
field: field, field: 'LASTNAME',
sort: sort, sort: 'ASC',
from: options.from || 0, from: options.from || 0,
limit: options.limit || AddonModGlossaryProvider.LIMIT_ENTRIES, limit: options.limit || AddonModGlossaryProvider.LIMIT_ENTRIES,
}; };
const preSets: CoreSiteWSPreSets = { const preSets: CoreSiteWSPreSets = {
cacheKey: this.getEntriesByAuthorCacheKey(glossaryId, letter, field, sort), cacheKey: this.getEntriesByAuthorCacheKey(glossaryId),
updateFrequency: CoreSite.FREQUENCY_SOMETIMES, updateFrequency: CoreSite.FREQUENCY_SOMETIMES,
component: AddonModGlossaryProvider.COMPONENT, component: AddonModGlossaryProvider.COMPONENT,
componentId: options.cmId, componentId: options.cmId,
@ -155,22 +145,12 @@ export class AddonModGlossaryProvider {
* Invalidate cache of entries by author. * Invalidate cache of entries by author.
* *
* @param glossaryId Glossary Id. * @param glossaryId Glossary Id.
* @param letter First letter of firstname or lastname, or either keywords: ALL or SPECIAL.
* @param field Search and order using: FIRSTNAME or LASTNAME
* @param sort The direction of the order: ASC or DESC
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @returns Resolved when data is invalidated.
*/ */
async invalidateEntriesByAuthor( async invalidateEntriesByAuthor(glossaryId: number, siteId?: string): Promise<void> {
glossaryId: number,
letter: string,
field: string,
sort: string,
siteId?: string,
): Promise<void> {
const site = await CoreSites.getSite(siteId); const site = await CoreSites.getSite(siteId);
const key = this.getEntriesByAuthorCacheKey(glossaryId, letter, field, sort); const key = this.getEntriesByAuthorCacheKey(glossaryId);
await site.invalidateWsCacheForKey(key); await site.invalidateWsCacheForKey(key);
} }
@ -179,26 +159,23 @@ export class AddonModGlossaryProvider {
* Get entries by category. * Get entries by category.
* *
* @param glossaryId Glossary Id. * @param glossaryId Glossary Id.
* @param categoryId The category ID. Use constant SHOW_ALL_CATEGORIES for all categories, or
* constant SHOW_NOT_CATEGORISED for uncategorised entries.
* @param options Other options. * @param options Other options.
* @returns Resolved with the entries. * @returns Resolved with the entries.
*/ */
async getEntriesByCategory( async getEntriesByCategory(
glossaryId: number, glossaryId: number,
categoryId: number,
options: AddonModGlossaryGetEntriesOptions = {}, options: AddonModGlossaryGetEntriesOptions = {},
): Promise<AddonModGlossaryGetEntriesByCategoryWSResponse> { ): Promise<AddonModGlossaryGetEntriesByCategoryWSResponse> {
const site = await CoreSites.getSite(options.siteId); const site = await CoreSites.getSite(options.siteId);
const params: AddonModGlossaryGetEntriesByCategoryWSParams = { const params: AddonModGlossaryGetEntriesByCategoryWSParams = {
id: glossaryId, id: glossaryId,
categoryid: categoryId, categoryid: AddonModGlossaryProvider.SHOW_ALL_CATEGORIES,
from: options.from || 0, from: options.from || 0,
limit: options.limit || AddonModGlossaryProvider.LIMIT_ENTRIES, limit: options.limit || AddonModGlossaryProvider.LIMIT_ENTRIES,
}; };
const preSets: CoreSiteWSPreSets = { const preSets: CoreSiteWSPreSets = {
cacheKey: this.getEntriesByCategoryCacheKey(glossaryId, categoryId), cacheKey: this.getEntriesByCategoryCacheKey(glossaryId),
updateFrequency: CoreSite.FREQUENCY_SOMETIMES, updateFrequency: CoreSite.FREQUENCY_SOMETIMES,
component: AddonModGlossaryProvider.COMPONENT, component: AddonModGlossaryProvider.COMPONENT,
componentId: options.cmId, componentId: options.cmId,
@ -212,15 +189,12 @@ export class AddonModGlossaryProvider {
* Invalidate cache of entries by category. * Invalidate cache of entries by category.
* *
* @param glossaryId Glossary Id. * @param glossaryId Glossary Id.
* @param categoryId The category ID. Use constant SHOW_ALL_CATEGORIES for all categories, or
* constant SHOW_NOT_CATEGORISED for uncategorised entries.
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @returns Resolved when data is invalidated.
*/ */
async invalidateEntriesByCategory(glossaryId: number, categoryId: number, siteId?: string): Promise<void> { async invalidateEntriesByCategory(glossaryId: number, siteId?: string): Promise<void> {
const site = await CoreSites.getSite(siteId); const site = await CoreSites.getSite(siteId);
const key = this.getEntriesByCategoryCacheKey(glossaryId, categoryId); const key = this.getEntriesByCategoryCacheKey(glossaryId);
await site.invalidateWsCacheForKey(key); await site.invalidateWsCacheForKey(key);
} }
@ -229,12 +203,12 @@ export class AddonModGlossaryProvider {
* Get the entries by category cache key. * Get the entries by category cache key.
* *
* @param glossaryId Glossary Id. * @param glossaryId Glossary Id.
* @param categoryId The category ID. Use constant SHOW_ALL_CATEGORIES for all categories, or
* constant SHOW_NOT_CATEGORISED for uncategorised entries.
* @returns Cache key. * @returns Cache key.
*/ */
getEntriesByCategoryCacheKey(glossaryId: number, categoryId: number): string { getEntriesByCategoryCacheKey(glossaryId: number): string {
return ROOT_CACHE_KEY + 'entriesByCategory:' + glossaryId + ':' + categoryId; const prefix = `${AddonModGlossaryProvider.ROOT_CACHE_KEY}entriesByCategory`;
return `${prefix}:${glossaryId}:${AddonModGlossaryProvider.SHOW_ALL_CATEGORIES}`;
} }
/** /**
@ -242,11 +216,10 @@ export class AddonModGlossaryProvider {
* *
* @param glossaryId Glossary Id. * @param glossaryId Glossary Id.
* @param order The way to order the records. * @param order The way to order the records.
* @param sort The direction of the order.
* @returns Cache key. * @returns Cache key.
*/ */
getEntriesByDateCacheKey(glossaryId: number, order: string, sort: string): string { getEntriesByDateCacheKey(glossaryId: number, order: string): string {
return ROOT_CACHE_KEY + 'entriesByDate:' + glossaryId + ':' + order + ':' + sort; return `${AddonModGlossaryProvider.ROOT_CACHE_KEY}entriesByDate:${glossaryId}:${order}:DESC`;
} }
/** /**
@ -254,14 +227,12 @@ export class AddonModGlossaryProvider {
* *
* @param glossaryId Glossary Id. * @param glossaryId Glossary Id.
* @param order The way to order the records. * @param order The way to order the records.
* @param sort The direction of the order.
* @param options Other options. * @param options Other options.
* @returns Resolved with the entries. * @returns Resolved with the entries.
*/ */
async getEntriesByDate( async getEntriesByDate(
glossaryId: number, glossaryId: number,
order: string, order: string,
sort: string,
options: AddonModGlossaryGetEntriesOptions = {}, options: AddonModGlossaryGetEntriesOptions = {},
): Promise<AddonModGlossaryGetEntriesWSResponse> { ): Promise<AddonModGlossaryGetEntriesWSResponse> {
const site = await CoreSites.getSite(options.siteId); const site = await CoreSites.getSite(options.siteId);
@ -269,12 +240,12 @@ export class AddonModGlossaryProvider {
const params: AddonModGlossaryGetEntriesByDateWSParams = { const params: AddonModGlossaryGetEntriesByDateWSParams = {
id: glossaryId, id: glossaryId,
order: order, order: order,
sort: sort, sort: 'DESC',
from: options.from || 0, from: options.from || 0,
limit: options.limit || AddonModGlossaryProvider.LIMIT_ENTRIES, limit: options.limit || AddonModGlossaryProvider.LIMIT_ENTRIES,
}; };
const preSets: CoreSiteWSPreSets = { const preSets: CoreSiteWSPreSets = {
cacheKey: this.getEntriesByDateCacheKey(glossaryId, order, sort), cacheKey: this.getEntriesByDateCacheKey(glossaryId, order),
updateFrequency: CoreSite.FREQUENCY_SOMETIMES, updateFrequency: CoreSite.FREQUENCY_SOMETIMES,
component: AddonModGlossaryProvider.COMPONENT, component: AddonModGlossaryProvider.COMPONENT,
componentId: options.cmId, componentId: options.cmId,
@ -289,14 +260,12 @@ export class AddonModGlossaryProvider {
* *
* @param glossaryId Glossary Id. * @param glossaryId Glossary Id.
* @param order The way to order the records. * @param order The way to order the records.
* @param sort The direction of the order.
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @returns Resolved when data is invalidated.
*/ */
async invalidateEntriesByDate(glossaryId: number, order: string, sort: string, siteId?: string): Promise<void> { async invalidateEntriesByDate(glossaryId: number, order: string, siteId?: string): Promise<void> {
const site = await CoreSites.getSite(siteId); const site = await CoreSites.getSite(siteId);
const key = this.getEntriesByDateCacheKey(glossaryId, order, sort); const key = this.getEntriesByDateCacheKey(glossaryId, order);
await site.invalidateWsCacheForKey(key); await site.invalidateWsCacheForKey(key);
} }
@ -305,24 +274,21 @@ export class AddonModGlossaryProvider {
* Get the entries by letter cache key. * Get the entries by letter cache key.
* *
* @param glossaryId Glossary Id. * @param glossaryId Glossary Id.
* @param letter A letter, or a special keyword.
* @returns Cache key. * @returns Cache key.
*/ */
protected getEntriesByLetterCacheKey(glossaryId: number, letter: string): string { protected getEntriesByLetterCacheKey(glossaryId: number): string {
return ROOT_CACHE_KEY + 'entriesByLetter:' + glossaryId + ':' + letter; return `${AddonModGlossaryProvider.ROOT_CACHE_KEY}entriesByLetter:${glossaryId}:ALL`;
} }
/** /**
* Get entries by letter. * Get entries by letter.
* *
* @param glossaryId Glossary Id. * @param glossaryId Glossary Id.
* @param letter A letter, or a special keyword.
* @param options Other options. * @param options Other options.
* @returns Resolved with the entries. * @returns Resolved with the entries.
*/ */
async getEntriesByLetter( async getEntriesByLetter(
glossaryId: number, glossaryId: number,
letter: string,
options: AddonModGlossaryGetEntriesOptions = {}, options: AddonModGlossaryGetEntriesOptions = {},
): Promise<AddonModGlossaryGetEntriesWSResponse> { ): Promise<AddonModGlossaryGetEntriesWSResponse> {
options.from = options.from || 0; options.from = options.from || 0;
@ -332,12 +298,12 @@ export class AddonModGlossaryProvider {
const params: AddonModGlossaryGetEntriesByLetterWSParams = { const params: AddonModGlossaryGetEntriesByLetterWSParams = {
id: glossaryId, id: glossaryId,
letter: letter, letter: 'ALL',
from: options.from, from: options.from,
limit: options.limit, limit: options.limit,
}; };
const preSets: CoreSiteWSPreSets = { const preSets: CoreSiteWSPreSets = {
cacheKey: this.getEntriesByLetterCacheKey(glossaryId, letter), cacheKey: this.getEntriesByLetterCacheKey(glossaryId),
updateFrequency: CoreSite.FREQUENCY_SOMETIMES, updateFrequency: CoreSite.FREQUENCY_SOMETIMES,
component: AddonModGlossaryProvider.COMPONENT, component: AddonModGlossaryProvider.COMPONENT,
componentId: options.cmId, componentId: options.cmId,
@ -362,16 +328,14 @@ export class AddonModGlossaryProvider {
* Invalidate cache of entries by letter. * Invalidate cache of entries by letter.
* *
* @param glossaryId Glossary Id. * @param glossaryId Glossary Id.
* @param letter A letter, or a special keyword.
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @returns Resolved when data is invalidated.
*/ */
async invalidateEntriesByLetter(glossaryId: number, letter: string, siteId?: string): Promise<void> { async invalidateEntriesByLetter(glossaryId: number, siteId?: string): Promise<void> {
const site = await CoreSites.getSite(siteId); const site = await CoreSites.getSite(siteId);
const key = this.getEntriesByLetterCacheKey(glossaryId, letter); const key = this.getEntriesByLetterCacheKey(glossaryId);
return site.invalidateWsCacheForKey(key); await site.invalidateWsCacheForKey(key);
} }
/** /**
@ -380,18 +344,10 @@ export class AddonModGlossaryProvider {
* @param glossaryId Glossary Id. * @param glossaryId Glossary Id.
* @param query The search query. * @param query The search query.
* @param fullSearch Whether or not full search is required. * @param fullSearch Whether or not full search is required.
* @param order The way to order the results.
* @param sort The direction of the order.
* @returns Cache key. * @returns Cache key.
*/ */
protected getEntriesBySearchCacheKey( protected getEntriesBySearchCacheKey(glossaryId: number, query: string, fullSearch: boolean): string {
glossaryId: number, return `${AddonModGlossaryProvider.ROOT_CACHE_KEY}entriesBySearch:${glossaryId}:${fullSearch}:CONCEPT:ASC:${query}`;
query: string,
fullSearch: boolean,
order: string,
sort: string,
): string {
return ROOT_CACHE_KEY + 'entriesBySearch:' + glossaryId + ':' + fullSearch + ':' + order + ':' + sort + ':' + query;
} }
/** /**
@ -400,8 +356,6 @@ export class AddonModGlossaryProvider {
* @param glossaryId Glossary Id. * @param glossaryId Glossary Id.
* @param query The search query. * @param query The search query.
* @param fullSearch Whether or not full search is required. * @param fullSearch Whether or not full search is required.
* @param order The way to order the results.
* @param sort The direction of the order.
* @param options Get entries options. * @param options Get entries options.
* @returns Resolved with the entries. * @returns Resolved with the entries.
*/ */
@ -409,8 +363,6 @@ export class AddonModGlossaryProvider {
glossaryId: number, glossaryId: number,
query: string, query: string,
fullSearch: boolean, fullSearch: boolean,
order: string,
sort: string,
options: AddonModGlossaryGetEntriesOptions = {}, options: AddonModGlossaryGetEntriesOptions = {},
): Promise<AddonModGlossaryGetEntriesWSResponse> { ): Promise<AddonModGlossaryGetEntriesWSResponse> {
const site = await CoreSites.getSite(options.siteId); const site = await CoreSites.getSite(options.siteId);
@ -419,13 +371,13 @@ export class AddonModGlossaryProvider {
id: glossaryId, id: glossaryId,
query: query, query: query,
fullsearch: fullSearch, fullsearch: fullSearch,
order: order, order: 'CONCEPT',
sort: sort, sort: 'ASC',
from: options.from || 0, from: options.from || 0,
limit: options.limit || AddonModGlossaryProvider.LIMIT_ENTRIES, limit: options.limit || AddonModGlossaryProvider.LIMIT_ENTRIES,
}; };
const preSets: CoreSiteWSPreSets = { const preSets: CoreSiteWSPreSets = {
cacheKey: this.getEntriesBySearchCacheKey(glossaryId, query, fullSearch, order, sort), cacheKey: this.getEntriesBySearchCacheKey(glossaryId, query, fullSearch),
updateFrequency: CoreSite.FREQUENCY_SOMETIMES, updateFrequency: CoreSite.FREQUENCY_SOMETIMES,
component: AddonModGlossaryProvider.COMPONENT, component: AddonModGlossaryProvider.COMPONENT,
componentId: options.cmId, componentId: options.cmId,
@ -441,22 +393,17 @@ export class AddonModGlossaryProvider {
* @param glossaryId Glossary Id. * @param glossaryId Glossary Id.
* @param query The search query. * @param query The search query.
* @param fullSearch Whether or not full search is required. * @param fullSearch Whether or not full search is required.
* @param order The way to order the results.
* @param sort The direction of the order.
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @returns Resolved when data is invalidated.
*/ */
async invalidateEntriesBySearch( async invalidateEntriesBySearch(
glossaryId: number, glossaryId: number,
query: string, query: string,
fullSearch: boolean, fullSearch: boolean,
order: string,
sort: string,
siteId?: string, siteId?: string,
): Promise<void> { ): Promise<void> {
const site = await CoreSites.getSite(siteId); const site = await CoreSites.getSite(siteId);
const key = this.getEntriesBySearchCacheKey(glossaryId, query, fullSearch, order, sort); const key = this.getEntriesBySearchCacheKey(glossaryId, query, fullSearch);
await site.invalidateWsCacheForKey(key); await site.invalidateWsCacheForKey(key);
} }
@ -468,7 +415,7 @@ export class AddonModGlossaryProvider {
* @returns The cache key. * @returns The cache key.
*/ */
protected getCategoriesCacheKey(glossaryId: number): string { protected getCategoriesCacheKey(glossaryId: number): string {
return ROOT_CACHE_KEY + 'categories:' + glossaryId; return AddonModGlossaryProvider.ROOT_CACHE_KEY + 'categories:' + glossaryId;
} }
/** /**
@ -533,7 +480,6 @@ export class AddonModGlossaryProvider {
* *
* @param glossaryId Glossary Id. * @param glossaryId Glossary Id.
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @returns Promise resolved when categories data has been invalidated,
*/ */
async invalidateCategories(glossaryId: number, siteId?: string): Promise<void> { async invalidateCategories(glossaryId: number, siteId?: string): Promise<void> {
const site = await CoreSites.getSite(siteId); const site = await CoreSites.getSite(siteId);
@ -548,7 +494,7 @@ export class AddonModGlossaryProvider {
* @returns Cache key. * @returns Cache key.
*/ */
protected getEntryCacheKey(entryId: number): string { protected getEntryCacheKey(entryId: number): string {
return ROOT_CACHE_KEY + 'getEntry:' + entryId; return `${AddonModGlossaryProvider.ROOT_CACHE_KEY}getEntry:${entryId}`;
} }
/** /**
@ -637,7 +583,7 @@ export class AddonModGlossaryProvider {
options: CoreCourseCommonModWSOptions = {}, options: CoreCourseCommonModWSOptions = {},
): Promise<AddonModGlossaryGetEntryByIdResponse> { ): Promise<AddonModGlossaryGetEntryByIdResponse> {
// Get the entries from this "page" and check if the entry we're looking for is in it. // Get the entries from this "page" and check if the entry we're looking for is in it.
const result = await this.getEntriesByLetter(glossaryId, 'ALL', { const result = await this.getEntriesByLetter(glossaryId, {
from: from, from: from,
readingStrategy: CoreSitesReadingStrategy.ONLY_CACHE, readingStrategy: CoreSitesReadingStrategy.ONLY_CACHE,
cmId: options.cmId, cmId: options.cmId,
@ -661,6 +607,30 @@ export class AddonModGlossaryProvider {
throw new CoreError('Entry not found.'); throw new CoreError('Entry not found.');
} }
/**
* Check whether the site can delete glossary entries.
*
* @param siteId Site id.
* @returns Whether the site can delete entries.
*/
async canDeleteEntries(siteId?: string): Promise<boolean> {
const site = await CoreSites.getSite(siteId);
return site.wsAvailable('mod_glossary_delete_entry');
}
/**
* Check whether the site can update glossary entries.
*
* @param siteId Site id.
* @returns Whether the site can update entries.
*/
async canUpdateEntries(siteId?: string): Promise<boolean> {
const site = await CoreSites.getSite(siteId);
return site.wsAvailable('mod_glossary_update_entry');
}
/** /**
* Performs the whole fetch of the entries using the proper function and arguments. * Performs the whole fetch of the entries using the proper function and arguments.
* *
@ -695,7 +665,6 @@ export class AddonModGlossaryProvider {
* *
* @param entryId Entry Id. * @param entryId Entry Id.
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @returns Resolved when data is invalidated.
*/ */
async invalidateEntry(entryId: number, siteId?: string): Promise<void> { async invalidateEntry(entryId: number, siteId?: string): Promise<void> {
const site = await CoreSites.getSite(siteId); const site = await CoreSites.getSite(siteId);
@ -708,7 +677,6 @@ export class AddonModGlossaryProvider {
* *
* @param entries Entry objects to invalidate. * @param entries Entry objects to invalidate.
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @returns Resolved when data is invalidated.
*/ */
protected async invalidateEntries(entries: AddonModGlossaryEntry[], siteId?: string): Promise<void> { protected async invalidateEntries(entries: AddonModGlossaryEntry[], siteId?: string): Promise<void> {
const keys: string[] = []; const keys: string[] = [];
@ -727,7 +695,6 @@ export class AddonModGlossaryProvider {
* *
* @param moduleId The module ID. * @param moduleId The module ID.
* @param courseId Course ID. * @param courseId Course ID.
* @returns Promise resolved when data is invalidated.
*/ */
async invalidateContent(moduleId: number, courseId: number): Promise<void> { async invalidateContent(moduleId: number, courseId: number): Promise<void> {
const glossary = await this.getGlossary(courseId, moduleId); const glossary = await this.getGlossary(courseId, moduleId);
@ -747,7 +714,6 @@ export class AddonModGlossaryProvider {
* @param glossary The glossary object. * @param glossary The glossary object.
* @param onlyEntriesList If true, entries won't be invalidated. * @param onlyEntriesList If true, entries won't be invalidated.
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @returns Promise resolved when data is invalidated.
*/ */
async invalidateGlossaryEntries(glossary: AddonModGlossaryGlossary, onlyEntriesList?: boolean, siteId?: string): Promise<void> { async invalidateGlossaryEntries(glossary: AddonModGlossaryGlossary, onlyEntriesList?: boolean, siteId?: string): Promise<void> {
siteId = siteId || CoreSites.getCurrentSiteId(); siteId = siteId || CoreSites.getCurrentSiteId();
@ -755,7 +721,7 @@ export class AddonModGlossaryProvider {
const promises: Promise<void>[] = []; const promises: Promise<void>[] = [];
if (!onlyEntriesList) { if (!onlyEntriesList) {
promises.push(this.fetchAllEntries((options) => this.getEntriesByLetter(glossary.id, 'ALL', options), { promises.push(this.fetchAllEntries((options) => this.getEntriesByLetter(glossary.id, options), {
cmId: glossary.coursemodule, cmId: glossary.coursemodule,
readingStrategy: CoreSitesReadingStrategy.PREFER_CACHE, readingStrategy: CoreSitesReadingStrategy.PREFER_CACHE,
siteId, siteId,
@ -765,21 +731,17 @@ export class AddonModGlossaryProvider {
glossary.browsemodes.forEach((mode) => { glossary.browsemodes.forEach((mode) => {
switch (mode) { switch (mode) {
case 'letter': case 'letter':
promises.push(this.invalidateEntriesByLetter(glossary.id, 'ALL', siteId)); promises.push(this.invalidateEntriesByLetter(glossary.id, siteId));
break; break;
case 'cat': case 'cat':
promises.push(this.invalidateEntriesByCategory( promises.push(this.invalidateEntriesByCategory(glossary.id, siteId));
glossary.id,
AddonModGlossaryProvider.SHOW_ALL_CATEGORIES,
siteId,
));
break; break;
case 'date': case 'date':
promises.push(this.invalidateEntriesByDate(glossary.id, 'CREATION', 'DESC', siteId)); promises.push(this.invalidateEntriesByDate(glossary.id, 'CREATION', siteId));
promises.push(this.invalidateEntriesByDate(glossary.id, 'UPDATE', 'DESC', siteId)); promises.push(this.invalidateEntriesByDate(glossary.id, 'UPDATE', siteId));
break; break;
case 'author': case 'author':
promises.push(this.invalidateEntriesByAuthor(glossary.id, 'ALL', 'LASTNAME', 'ASC', siteId)); promises.push(this.invalidateEntriesByAuthor(glossary.id, siteId));
break; break;
default: default:
} }
@ -857,13 +819,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,
}); });
@ -877,17 +836,16 @@ export class AddonModGlossaryProvider {
throw new CoreError('Error adding entry.'); throw new CoreError('Error adding entry.');
} }
await AddonModGlossaryOffline.addNewEntry( await AddonModGlossaryOffline.addOfflineEntry(
glossaryId, glossaryId,
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;
@ -898,19 +856,9 @@ export class AddonModGlossaryProvider {
return storeOffline(); return storeOffline();
} }
// If we are editing an offline entry, discard previous first.
if (otherOptions.discardEntry) {
await AddonModGlossaryOffline.deleteNewEntry(
glossaryId,
otherOptions.discardEntry.concept,
otherOptions.discardEntry.timecreated,
otherOptions.siteId,
);
}
try { try {
// Try to add it in online. // Try to add it in online.
return await this.addEntryOnline( const entryId = await this.addEntryOnline(
glossaryId, glossaryId,
concept, concept,
definition, definition,
@ -918,6 +866,8 @@ export class AddonModGlossaryProvider {
<number> attachments, <number> attachments,
otherOptions.siteId, otherOptions.siteId,
); );
return entryId;
} catch (error) { } catch (error) {
if (otherOptions.allowOffline && !CoreUtils.isWebServiceError(error)) { if (otherOptions.allowOffline && !CoreUtils.isWebServiceError(error)) {
// Couldn't connect to server, store in offline. // Couldn't connect to server, store in offline.
@ -959,7 +909,7 @@ export class AddonModGlossaryProvider {
}; };
if (attachId) { if (attachId) {
params.options!.push({ params.options?.push({
name: 'attachmentsid', name: 'attachmentsid',
value: String(attachId), value: String(attachId),
}); });
@ -967,9 +917,71 @@ export class AddonModGlossaryProvider {
const response = await site.write<AddonModGlossaryAddEntryWSResponse>('mod_glossary_add_entry', params); const response = await site.write<AddonModGlossaryAddEntryWSResponse>('mod_glossary_add_entry', params);
CoreEvents.trigger(GLOSSARY_ENTRY_ADDED, { glossaryId, entryId: response.entryid }, siteId);
return response.entryid; return response.entryid;
} }
/**
* Update an existing entry on a glossary.
*
* @param glossaryId Glossary ID.
* @param entryId Entry ID.
* @param concept Glossary entry concept.
* @param definition Glossary entry concept definition.
* @param options Options for the entry.
* @param attachId Attachments ID (if any attachment).
* @param siteId Site ID. If not defined, current site.
*/
async updateEntry(
glossaryId: number,
entryId: number,
concept: string,
definition: string,
options?: Record<string, AddonModGlossaryEntryOption>,
attachId?: number,
siteId?: string,
): Promise<void> {
const site = await CoreSites.getSite(siteId);
const params: AddonModGlossaryUpdateEntryWSParams = {
entryid: entryId,
concept: concept,
definition: definition,
definitionformat: 1,
options: CoreUtils.objectToArrayOfObjects(options || {}, 'name', 'value'),
};
if (attachId) {
params.options?.push({
name: 'attachmentsid',
value: String(attachId),
});
}
const response = await site.write<AddonModGlossaryUpdateEntryWSResponse>('mod_glossary_update_entry', params);
if (!response.result) {
throw new CoreError(response.warnings?.[0].message ?? 'Error updating entry');
}
CoreEvents.trigger(GLOSSARY_ENTRY_UPDATED, { glossaryId, entryId }, siteId);
}
/**
* Delete entry.
*
* @param glossaryId Glossary id.
* @param entryId Entry id.
*/
async deleteEntry(glossaryId: number, entryId: number): Promise<void> {
const site = CoreSites.getRequiredCurrentSite();
await site.write('mod_glossary_delete_entry', { entryid: entryId });
CoreEvents.trigger(GLOSSARY_ENTRY_DELETED, { glossaryId, entryId });
}
/** /**
* Check if a entry concept is already used. * Check if a entry concept is already used.
* *
@ -989,7 +1001,7 @@ export class AddonModGlossaryProvider {
// If we get here, there's no offline entry with this name, check online. // If we get here, there's no offline entry with this name, check online.
// Get entries from the cache. // Get entries from the cache.
const entries = await this.fetchAllEntries((options) => this.getEntriesByLetter(glossaryId, 'ALL', options), { const entries = await this.fetchAllEntries((options) => this.getEntriesByLetter(glossaryId, options), {
cmId: options.cmId, cmId: options.cmId,
readingStrategy: CoreSitesReadingStrategy.PREFER_CACHE, readingStrategy: CoreSitesReadingStrategy.PREFER_CACHE,
siteId: options.siteId, siteId: options.siteId,
@ -1010,15 +1022,14 @@ export class AddonModGlossaryProvider {
* @param mode The mode in which the glossary was viewed. * @param mode The mode in which the glossary was viewed.
* @param name Name of the glossary. * @param name Name of the glossary.
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @returns Promise resolved when the WS call is successful.
*/ */
logView(glossaryId: number, mode: string, name?: string, siteId?: string): Promise<void> { async logView(glossaryId: number, mode: string, name?: string, siteId?: string): Promise<void> {
const params: AddonModGlossaryViewGlossaryWSParams = { const params: AddonModGlossaryViewGlossaryWSParams = {
id: glossaryId, id: glossaryId,
mode: mode, mode: mode,
}; };
return CoreCourseLogHelper.logSingle( await CoreCourseLogHelper.logSingle(
'mod_glossary_view_glossary', 'mod_glossary_view_glossary',
params, params,
AddonModGlossaryProvider.COMPONENT, AddonModGlossaryProvider.COMPONENT,
@ -1037,14 +1048,13 @@ export class AddonModGlossaryProvider {
* @param glossaryId Glossary ID. * @param glossaryId Glossary ID.
* @param name Name of the glossary. * @param name Name of the glossary.
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @returns Promise resolved when the WS call is successful.
*/ */
logEntryView(entryId: number, glossaryId: number, name?: string, siteId?: string): Promise<void> { async logEntryView(entryId: number, glossaryId: number, name?: string, siteId?: string): Promise<void> {
const params: AddonModGlossaryViewEntryWSParams = { const params: AddonModGlossaryViewEntryWSParams = {
id: entryId, id: entryId,
}; };
return CoreCourseLogHelper.logSingle( await CoreCourseLogHelper.logSingle(
'mod_glossary_view_entry', 'mod_glossary_view_entry',
params, params,
AddonModGlossaryProvider.COMPONENT, AddonModGlossaryProvider.COMPONENT,
@ -1063,7 +1073,6 @@ export class AddonModGlossaryProvider {
* @param entries Entries. * @param entries Entries.
* @param from The "page" the entries belong to. * @param from The "page" the entries belong to.
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @returns Promise resolved when done.
*/ */
protected async storeEntries( protected async storeEntries(
glossaryId: number, glossaryId: number,
@ -1081,7 +1090,6 @@ export class AddonModGlossaryProvider {
* @param entryId Entry ID. * @param entryId Entry ID.
* @param from The "page" the entry belongs to. * @param from The "page" the entry belongs to.
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @returns Promise resolved when done.
*/ */
protected async storeEntryId(glossaryId: number, entryId: number, from: number, siteId?: string): Promise<void> { protected async storeEntryId(glossaryId: number, entryId: number, from: number, siteId?: string): Promise<void> {
const site = await CoreSites.getSite(siteId); const site = await CoreSites.getSite(siteId);
@ -1107,18 +1115,38 @@ declare module '@singletons/events' {
* @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation * @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation
*/ */
export interface CoreEventsData { export interface CoreEventsData {
[AddonModGlossaryProvider.ADD_ENTRY_EVENT]: AddonModGlossaryAddEntryEventData; [GLOSSARY_ENTRY_ADDED]: AddonModGlossaryEntryAddedEventData;
[AddonModGlossarySyncProvider.AUTO_SYNCED]: AddonModGlossaryAutoSyncData; [GLOSSARY_ENTRY_UPDATED]: AddonModGlossaryEntryUpdatedEventData;
[GLOSSARY_ENTRY_DELETED]: AddonModGlossaryEntryDeletedEventData;
} }
} }
/** /**
* Data passed to ADD_ENTRY_EVENT. * GLOSSARY_ENTRY_ADDED event payload.
*/ */
export type AddonModGlossaryAddEntryEventData = { export type AddonModGlossaryEntryAddedEventData = {
glossaryId: number; glossaryId: number;
entryId?: number; entryId?: number;
timecreated?: number;
};
/**
* GLOSSARY_ENTRY_UPDATED event payload.
*/
export type AddonModGlossaryEntryUpdatedEventData = {
glossaryId: number;
entryId?: number;
timecreated?: number;
};
/**
* GLOSSARY_ENTRY_DELETED event payload.
*/
export type AddonModGlossaryEntryDeletedEventData = {
glossaryId: number;
entryId?: number;
timecreated?: number;
}; };
/** /**
@ -1369,6 +1397,35 @@ export type AddonModGlossaryAddEntryWSResponse = {
warnings?: CoreWSExternalWarning[]; warnings?: CoreWSExternalWarning[];
}; };
/**
* Params of mod_glossary_update_entry WS.
*/
export type AddonModGlossaryUpdateEntryWSParams = {
entryid: number; // Glossary entry id to update.
concept: string; // Glossary concept.
definition: string; // Glossary concept definition.
definitionformat: number; // Definition format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN).
options?: { // Optional settings.
name: string; // The allowed keys (value format) are:
// inlineattachmentsid (int); the draft file area id for inline attachments
// attachmentsid (int); the draft file area id for attachments
// categories (comma separated int); comma separated category ids
// aliases (comma separated str); comma separated aliases
// usedynalink (bool); whether the entry should be automatically linked.
// casesensitive (bool); whether the entry is case sensitive.
// fullmatch (bool); whether to match whole words only.
value: string | number; // The value of the option (validated inside the function).
}[];
};
/**
* Data returned by mod_glossary_update_entry WS.
*/
export type AddonModGlossaryUpdateEntryWSResponse = {
result: boolean; // The update result.
warnings?: CoreWSExternalWarning[];
};
/** /**
* Params of mod_glossary_view_glossary WS. * Params of mod_glossary_view_glossary WS.
*/ */
@ -1389,37 +1446,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;
};
/**
* Entry to be added.
*/
export type AddonModGlossaryNewEntry = {
concept: string;
definition: string;
timecreated: number;
};
/**
* Entry to be added, including attachments.
*/
export type AddonModGlossaryNewEntryWithFiles = AddonModGlossaryNewEntry & {
files: CoreFileEntry[];
};
/** /**
* Options to pass to the different get entries functions. * Options to pass to the different get entries functions.
*/ */

View File

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

View File

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

View File

@ -45,7 +45,7 @@ export class AddonModGlossaryPrefetchHandlerService extends CoreCourseActivityPr
const glossary = await AddonModGlossary.getGlossary(courseId, module.id); const glossary = await AddonModGlossary.getGlossary(courseId, module.id);
const entries = await AddonModGlossary.fetchAllEntries( const entries = await AddonModGlossary.fetchAllEntries(
(options) => AddonModGlossary.getEntriesByLetter(glossary.id, 'ALL', options), (options) => AddonModGlossary.getEntriesByLetter(glossary.id, options),
{ {
cmId: module.id, cmId: module.id,
}, },
@ -125,43 +125,23 @@ export class AddonModGlossaryPrefetchHandlerService extends CoreCourseActivityPr
break; break;
case 'cat': case 'cat':
promises.push(AddonModGlossary.fetchAllEntries( promises.push(AddonModGlossary.fetchAllEntries(
(newOptions) => AddonModGlossary.getEntriesByCategory( (newOptions) => AddonModGlossary.getEntriesByCategory(glossary.id, newOptions),
glossary.id,
AddonModGlossaryProvider.SHOW_ALL_CATEGORIES,
newOptions,
),
options, options,
)); ));
break; break;
case 'date': case 'date':
promises.push(AddonModGlossary.fetchAllEntries( promises.push(AddonModGlossary.fetchAllEntries(
(newOptions) => AddonModGlossary.getEntriesByDate( (newOptions) => AddonModGlossary.getEntriesByDate(glossary.id, 'CREATION', newOptions),
glossary.id,
'CREATION',
'DESC',
newOptions,
),
options, options,
)); ));
promises.push(AddonModGlossary.fetchAllEntries( promises.push(AddonModGlossary.fetchAllEntries(
(newOptions) => AddonModGlossary.getEntriesByDate( (newOptions) => AddonModGlossary.getEntriesByDate(glossary.id, 'UPDATE', newOptions),
glossary.id,
'UPDATE',
'DESC',
newOptions,
),
options, options,
)); ));
break; break;
case 'author': case 'author':
promises.push(AddonModGlossary.fetchAllEntries( promises.push(AddonModGlossary.fetchAllEntries(
(newOptions) => AddonModGlossary.getEntriesByAuthor( (newOptions) => AddonModGlossary.getEntriesByAuthor(glossary.id, newOptions),
glossary.id,
'ALL',
'LASTNAME',
'ASC',
newOptions,
),
options, options,
)); ));
break; break;
@ -171,7 +151,7 @@ export class AddonModGlossaryPrefetchHandlerService extends CoreCourseActivityPr
// Fetch all entries to get information from. // Fetch all entries to get information from.
promises.push(AddonModGlossary.fetchAllEntries( promises.push(AddonModGlossary.fetchAllEntries(
(newOptions) => AddonModGlossary.getEntriesByLetter(glossary.id, 'ALL', newOptions), (newOptions) => AddonModGlossary.getEntriesByLetter(glossary.id, newOptions),
options, options,
).then((entries) => { ).then((entries) => {
const promises: Promise<unknown>[] = []; const promises: Promise<unknown>[] = [];

View File

@ -154,6 +154,152 @@ 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
Scenario: Edit entries
Given I entered the glossary activity "Test glossary" on course "Course 1" as "student1" in the app
# Online
When I press "Cucumber" in the app
And I press "Edit entry" in the app
Then the field "Concept" matches value "Cucumber" in the app
And the field "Definition" matches value "Sweet cucumber" in the app
But I should not find "Keyword(s)" in the app
And I should not find "Categories" in the app
When I set the following fields to these values in the app:
| Concept | Coconut |
| Definition | Coconut is a fruit |
And I press "Add file" in the app
And I upload "stub1.txt" to "File" ".action-sheet-button" in the app
And I press "Add file" in the app
And I upload "stub2.txt" to "File" ".action-sheet-button" in the app
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
Then I should find "Coconut is a fruit" in the app
And I should find "stub1.txt" in the app
And I should find "stub2.txt" in the app
But I should not find "Cucumber is a fruit" in the app
When I press "Edit entry" in the app
Then I should find "stub1.txt" in the app
And I should find "stub2.txt" 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 press "Delete" within "stub2.txt" "ion-item" in the app
And I press "Delete" near "Are you sure you want to delete this file?" in the app
And I press "Add file" in the app
And I upload "stub3.txt" to "File" ".action-sheet-button" in the app
And I press "Save" in the app
Then I should find "stub1.txt" in the app
And I should find "stub3.txt" in the app
But I should not find "stub2.txt" in the app
When I press the back button in the app
Then I should find "Coconut" in the app
And I should find "Potato" in the app
But I should not find "Cucumber" in the app
# Offline
When I press "Add a new entry" in the app
And I switch network connection to offline
And I set the following fields to these values in the app:
| Concept | Broccoli |
| Definition | Brassica oleracea var. italica |
| Keyword(s) | vegetable, healthy |
And I press "Categories" in the app
And I press "The ones I like" in the app
And I press "OK" in the app
And I press "Add file" in the app
And I upload "stub1.txt" to "File" ".action-sheet-button" in the app
And I press "Add file" in the app
And I upload "stub2.txt" to "File" ".action-sheet-button" in the app
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
Then I should find "Potato" in the app
And I should find "Broccoli" in the app
When I press "Broccoli" in the app
Then I should find "Brassica oleracea var. italica" in the app
And I should find "stub1.txt" in the app
And I should find "stub2.txt" in the app
When I press "Edit entry" 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 "Keyword(s)" matches value "vegetable, healthy" in the app
And I should find "The ones I like" in the app
And I should find "stub1.txt" in the app
And I should find "stub2.txt" 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:
| Concept | Pickle |
| Definition | Pickle Rick |
And I press "Delete" within "stub2.txt" "ion-item" in the app
And I press "Delete" near "Are you sure you want to delete this file?" in the app
And I press "Add file" in the app
And I upload "stub3.txt" to "File" ".action-sheet-button" in the app
And I press "Save" in the app
Then I should find "Pickle Rick" in the app
And I should find "stub1.txt" in the app
And I should find "stub3.txt" in the app
But I should not find "stub2.txt" in the app
And I should not find "Brassica oleracea var. italica" in the app
When I press the back button in the app
Then I should find "Pickle" in the app
And I should find "Potato" in the app
But I should not find "Broccoli" in the app
When I switch network connection to wifi
And I press "Information" in the app
And I press "Synchronise now" in the app
Then I should not find "This Glossary has offline data to be synchronised" in the app
When I press "Pickle" in the app
Then I should find "Pickle Rick" in the app
And I should find "stub1.txt" in the app
And I should find "stub3.txt" in the app
But I should not find "stub2.txt" in the app
And I should not find "Brassica oleracea var. italica" in the app
Scenario: Delete entries
Given I entered the glossary activity "Test glossary" on course "Course 1" as "student1" in the app
# Online
When I press "Cucumber" in the app
And I press "Delete entry" in the app
And I press "OK" near "Are you sure you want to delete this entry?" in the app
Then I should find "Entry deleted" in the app
And I should find "Potato" in the app
But I should not find "Cucumber" in the app
# Offline
When I press "Add a new entry" in the app
And I switch network connection to offline
And I set the following fields to these values in the app:
| Concept | Broccoli |
| Definition | Brassica oleracea var. italica |
And I press "Save" in the app
Then I should find "Potato" in the app
And I should find "Broccoli" in the app
When I press "Broccoli" in the app
Then I should find "Brassica oleracea var. italica" in the app
When I press "Delete entry" in the app
And I press "OK" near "Are you sure you want to delete this entry?" in the app
Then I should find "Entry deleted" in the app
And I should find "Potato" in the app
But I should not find "Broccoli" in the app
Scenario: Sync Scenario: Sync
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
And I press "Add a new entry" in the app And I press "Add a new entry" in the app
@ -192,8 +338,8 @@ Feature: Test basic usage of glossary in app
And I should find "Broccoli" in the app And I should find "Broccoli" in the app
And I should find "Cabbage" in the app And I should find "Cabbage" in the app
And I should find "Garlic" in the app And I should find "Garlic" in the app
But I should not see "Entries to be synced" But I should not find "Entries to be synced" in the app
And I should not see "This Glossary has offline data to be synchronised." And I should not find "This Glossary has offline data to be synchronised." in the app
When I press "Garlic" in the app When I press "Garlic" in the app
Then I should find "Garlic" in the app Then I should find "Garlic" in the app

View File

@ -0,0 +1 @@
This is a stub.

View File

@ -0,0 +1 @@
This is a stub.

View File

@ -0,0 +1 @@
This is a stub.

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
@ -280,6 +291,7 @@ Feature: Test glossary navigation
| Concept | Tomato | | Concept | Tomato |
| Definition | Tomato is a fruit | | Definition | Tomato is a fruit |
And I press "Save" in the app 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: And I set the following fields to these values in the app:
| Concept | Cashew | | Concept | Cashew |
| Definition | Cashew is a fruit | | Definition | Cashew is a fruit |
@ -300,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

View File

@ -1772,9 +1772,9 @@ export class CoreUtilsProvider {
* @param fallback Value to return if the promise is rejected. * @param fallback Value to return if the promise is rejected.
* @returns Promise with ignored errors, resolving to the fallback result if provided. * @returns Promise with ignored errors, resolving to the fallback result if provided.
*/ */
async ignoreErrors<Result>(promise: Promise<Result>): Promise<Result | undefined>; async ignoreErrors<Result>(promise?: Promise<Result>): Promise<Result | undefined>;
async ignoreErrors<Result, Fallback>(promise: Promise<Result>, fallback: Fallback): Promise<Result | Fallback>; async ignoreErrors<Result, Fallback>(promise: Promise<Result>, fallback: Fallback): Promise<Result | Fallback>;
async ignoreErrors<Result, Fallback>(promise: Promise<Result>, fallback?: Fallback): Promise<Result | Fallback | undefined> { async ignoreErrors<Result, Fallback>(promise?: Promise<Result>, fallback?: Fallback): Promise<Result | Fallback | undefined> {
try { try {
const result = await promise; const result = await promise;

View File

@ -421,7 +421,7 @@ export class TestingBehatDomUtilsService {
*/ */
findElementBasedOnText( findElementBasedOnText(
locator: TestingBehatElementLocator, locator: TestingBehatElementLocator,
options: TestingBehatFindOptions, options: TestingBehatFindOptions = {},
): HTMLElement | undefined { ): HTMLElement | undefined {
return this.findElementsBasedOnText(locator, options)[0]; return this.findElementsBasedOnText(locator, options)[0];
} }
@ -437,7 +437,7 @@ export class TestingBehatDomUtilsService {
locator: TestingBehatElementLocator, locator: TestingBehatElementLocator,
options: TestingBehatFindOptions, options: TestingBehatFindOptions,
): HTMLElement[] { ): HTMLElement[] {
const topContainers = this.getCurrentTopContainerElements(options.containerName); const topContainers = this.getCurrentTopContainerElements(options.containerName ?? '');
let elements: HTMLElement[] = []; let elements: HTMLElement[] = [];
for (let i = 0; i < topContainers.length; i++) { for (let i = 0; i < topContainers.length; i++) {

View File

@ -361,6 +361,40 @@ export class TestingBehatRuntimeService {
} }
} }
/**
* Get a file input id, adding it if necessary.
*
* @param locator Input locator.
* @returns Input id if successful, or ERROR: followed by message
*/
async getFileInputId(locator: TestingBehatElementLocator): Promise<string> {
this.log('Action - Upload File', { locator });
try {
const inputOrContainer = TestingBehatDomUtils.findElementBasedOnText(locator);
if (!inputOrContainer) {
return 'ERROR: No element matches input locator.';
}
const input = inputOrContainer.matches('input[type="file"]')
? inputOrContainer
: inputOrContainer.querySelector('input[type="file"]');
if (!input) {
return 'ERROR: Input element does not contain a file input.';
}
if (!input.hasAttribute('id')) {
input.setAttribute('id', `file-${Date.now()}`);
}
return input.getAttribute('id') ?? '';
} catch (error) {
return 'ERROR: ' + error.message;
}
}
/** /**
* Trigger a pull to refresh gesture in the current page. * Trigger a pull to refresh gesture in the current page.
* *
@ -635,8 +669,8 @@ export type BehatTestsWindow = Window & {
}; };
export type TestingBehatFindOptions = { export type TestingBehatFindOptions = {
containerName: string; containerName?: string;
onlyClickable: boolean; onlyClickable?: boolean;
}; };
export type TestingBehatElementLocator = { export type TestingBehatElementLocator = {

View File

@ -6,6 +6,7 @@ information provided here is intended especially for developers.
- CoreIconComponent has been removed after deprecation period: Use CoreFaIconDirective instead. - CoreIconComponent has been removed after deprecation period: Use CoreFaIconDirective instead.
- The courseSummaryComponent property has been removed from the CoreCourseFormatComponent component, and the getCourseSummaryComponent method from the CoreCourseFormatHandler interface. - The courseSummaryComponent property has been removed from the CoreCourseFormatComponent component, and the getCourseSummaryComponent method from the CoreCourseFormatHandler interface.
- Font Awesome icon library has been updated to 6.3.0. - Font Awesome icon library has been updated to 6.3.0.
- Some methods in glossary addon services have changed.
=== 4.1.0 === === 4.1.0 ===