diff --git a/gulpfile.js b/gulpfile.js index d7098d9b7..295e4a6df 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -71,5 +71,14 @@ gulp.task('watch', () => { }); 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') + ); }); diff --git a/local_moodleappbehat/tests/behat/behat_app.php b/local_moodleappbehat/tests/behat/behat_app.php index 04395fbaf..67776a49f 100644 --- a/local_moodleappbehat/tests/behat/behat_app.php +++ b/local_moodleappbehat/tests/behat/behat_app.php @@ -44,27 +44,21 @@ class behat_app extends behat_app_helper { ], ]; + protected $featurepath = ''; protected $windowsize = '360x720'; /** * @BeforeScenario */ public function before_scenario(ScenarioScope $scope) { - if (!$scope->getFeature()->hasTag('app')) { + $feature = $scope->getFeature(); + + if (!$feature->hasTag('app')) { return; } - 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=/';"); + $this->featurepath = dirname($feature->getFile()); + $this->configure_performance_logs(); } /** @@ -89,6 +83,23 @@ class behat_app extends behat_app_helper { $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. */ @@ -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. * diff --git a/scripts/build-behat-plugin.js b/scripts/build-behat-plugin.js index 621ad007f..04daa3e94 100755 --- a/scripts/build-behat-plugin.js +++ b/scripts/build-behat-plugin.js @@ -33,7 +33,7 @@ async function main() { : []; if (!existsSync(pluginPath)) { - mkdirSync(pluginPath); + mkdirSync(pluginPath, { recursive: true }); } else { // Empty directory, except the excluding list. const excludeFromErase = [ @@ -76,21 +76,29 @@ async function main() { }; writeFileSync(pluginFilePath, replaceArguments(fileContents, replacements)); - // Copy feature and snapshot files. + // Copy features, snapshots, and fixtures. if (!excludeFeatures) { const behatTempFeaturesPath = `${pluginPath}/behat-tmp`; copySync(projectPath('src'), behatTempFeaturesPath, { filter: shouldCopyFileOrDirectory }); const behatFeaturesPath = `${pluginPath}/tests/behat`; if (!existsSync(behatFeaturesPath)) { - mkdirSync(behatFeaturesPath, {recursive: true}); + mkdirSync(behatFeaturesPath, { recursive: true }); } for await (const file of getDirectoryFiles(behatTempFeaturesPath)) { const filePath = dirname(file); + const snapshotsIndex = file.indexOf('/tests/behat/snapshots/'); + const fixturesIndex = file.indexOf('/tests/behat/fixtures/'); - if (filePath.endsWith('/tests/behat/snapshots')) { - renameSync(file, behatFeaturesPath + '/snapshots/' + basename(file)); + if (snapshotsIndex !== -1) { + moveFile(file, behatFeaturesPath + '/snapshots/' + file.slice(snapshotsIndex + 23)); + + continue; + } + + if (fixturesIndex !== -1) { + moveFile(file, behatFeaturesPath + '/fixtures/' + file.slice(fixturesIndex + 22)); continue; } @@ -103,7 +111,7 @@ async function main() { const searchRegExp = /\//g; const prefix = relative(behatTempFeaturesPath, newPath).replace(searchRegExp,'-') || 'core'; const featureFilename = prefix + '-' + basename(file); - renameSync(file, behatFeaturesPath + '/' + featureFilename); + moveFile(file, behatFeaturesPath + '/' + featureFilename); } rmSync(behatTempFeaturesPath, {recursive: true}); @@ -115,7 +123,8 @@ function shouldCopyFileOrDirectory(path) { return stats.isDirectory() || extname(path) === '.feature' - || extname(path) === '.png'; + || path.includes('/tests/behat/snapshots') + || path.includes('/tests/behat/fixtures'); } function isExcluded(file, exclusions) { @@ -127,6 +136,16 @@ function fail(message) { process.exit(1); } +function moveFile(from, to) { + const targetDir = dirname(to); + + if (!existsSync(targetDir)) { + mkdirSync(targetDir, { recursive: true }); + } + + renameSync(from, to); +} + function guessPluginPath() { if (process.env.MOODLE_APP_BEHAT_PLUGIN_PATH) { return process.env.MOODLE_APP_BEHAT_PLUGIN_PATH; diff --git a/scripts/langindex.json b/scripts/langindex.json index 0aa311eec..64a23a98d 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -681,6 +681,7 @@ "addon.mod_forum.yourreply": "forum", "addon.mod_glossary.addentry": "glossary", "addon.mod_glossary.aliases": "glossary", + "addon.mod_glossary.areyousuredelete": "glossary", "addon.mod_glossary.attachment": "glossary", "addon.mod_glossary.browsemode": "local_moodlemobileapp", "addon.mod_glossary.byalphabet": "local_moodlemobileapp", @@ -694,9 +695,14 @@ "addon.mod_glossary.categories": "glossary", "addon.mod_glossary.concept": "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.entry": "glossary", + "addon.mod_glossary.entrydeleted": "glossary", "addon.mod_glossary.entrypendingapproval": "local_moodlemobileapp", "addon.mod_glossary.entryusedynalink": "glossary", + "addon.mod_glossary.errordeleting": "local_moodlemobileapp", "addon.mod_glossary.errconceptalreadyexists": "glossary", "addon.mod_glossary.errorloadingentries": "local_moodlemobileapp", "addon.mod_glossary.errorloadingentry": "local_moodlemobileapp", diff --git a/src/addons/mod/glossary/classes/glossary-entries-source.ts b/src/addons/mod/glossary/classes/glossary-entries-source.ts index 7fd2e2814..08d525dc4 100644 --- a/src/addons/mod/glossary/classes/glossary-entries-source.ts +++ b/src/addons/mod/glossary/classes/glossary-entries-source.ts @@ -29,8 +29,6 @@ import { AddonModGlossaryOffline, AddonModGlossaryOfflineEntry } from '../servic */ export class AddonModGlossaryEntriesSource extends CoreRoutedItemsManagerSource { - static readonly NEW_ENTRY: AddonModGlossaryNewEntryForm = { newEntry: true }; - readonly COURSE_ID: number; readonly CM_ID: number; readonly GLOSSARY_PATH_PREFIX: string; @@ -54,16 +52,6 @@ export class AddonModGlossaryEntriesSource extends CoreRoutedItemsManagerSource< this.GLOSSARY_PATH_PREFIX = glossaryPathPrefix; } - /** - * Type guard to infer NewEntryForm objects. - * - * @param entry Item to check. - * @returns Whether the item is a new entry form. - */ - isNewEntryForm(entry: AddonModGlossaryEntryItem): entry is AddonModGlossaryNewEntryForm { - return 'newEntry' in entry; - } - /** * Type guard to infer entry objects. * @@ -81,38 +69,28 @@ export class AddonModGlossaryEntriesSource extends CoreRoutedItemsManagerSource< * @returns Whether the item is an offline entry. */ isOfflineEntry(entry: AddonModGlossaryEntryItem): entry is AddonModGlossaryOfflineEntry { - return !this.isNewEntryForm(entry) && !this.isOnlineEntry(entry); + return !this.isOnlineEntry(entry); } /** * @inheritdoc */ getItemPath(entry: AddonModGlossaryEntryItem): string { - if (this.isOnlineEntry(entry)) { - return `${this.GLOSSARY_PATH_PREFIX}entry/${entry.id}`; - } - if (this.isOfflineEntry(entry)) { - return `${this.GLOSSARY_PATH_PREFIX}edit/${entry.timecreated}`; + return `${this.GLOSSARY_PATH_PREFIX}entry/new-${entry.timecreated}`; } - return `${this.GLOSSARY_PATH_PREFIX}edit/0`; + return `${this.GLOSSARY_PATH_PREFIX}entry/${entry.id}`; } /** * @inheritdoc */ - getItemQueryParams(entry: AddonModGlossaryEntryItem): Params { - const params: Params = { + getItemQueryParams(): Params { + return { cmId: this.CM_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; - this.fetchFunction = (options) => AddonModGlossary.getEntriesBySearch( - glossaryId, - query, - true, - 'CONCEPT', - 'ASC', - options, - ); - this.fetchInvalidate = () => AddonModGlossary.invalidateEntriesBySearch( - glossaryId, - query, - true, - 'CONCEPT', - 'ASC', - ); + this.fetchFunction = (options) => AddonModGlossary.getEntriesBySearch(glossaryId, query, true, options); + this.fetchInvalidate = () => AddonModGlossary.invalidateEntriesBySearch(glossaryId, query, true); this.hasSearched = true; this.setDirty(true); } @@ -192,12 +157,14 @@ export class AddonModGlossaryEntriesSource extends CoreRoutedItemsManagerSource< /** * Invalidate glossary cache. + * + * @param invalidateGlossary Whether to invalidate the entire glossary or not */ - async invalidateCache(): Promise { - await Promise.all([ - AddonModGlossary.invalidateCourseGlossaries(this.COURSE_ID), + async invalidateCache(invalidateGlossary: boolean = true): Promise { + await Promise.all([ 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': // Browse by author. this.viewMode = 'author'; - this.fetchFunction = (options) => AddonModGlossary.getEntriesByAuthor( - glossaryId, - 'ALL', - 'LASTNAME', - 'ASC', - options, - ); - this.fetchInvalidate = () => AddonModGlossary.invalidateEntriesByAuthor( - glossaryId, - 'ALL', - 'LASTNAME', - 'ASC', - ); + this.fetchFunction = (options) => AddonModGlossary.getEntriesByAuthor(glossaryId, options); + this.fetchInvalidate = () => AddonModGlossary.invalidateEntriesByAuthor(glossaryId); break; case 'cat_all': // Browse by category. this.viewMode = 'cat'; - this.fetchFunction = (options) => AddonModGlossary.getEntriesByCategory( - glossaryId, - AddonModGlossaryProvider.SHOW_ALL_CATEGORIES, - options, - ); - this.fetchInvalidate = () => AddonModGlossary.invalidateEntriesByCategory( - glossaryId, - AddonModGlossaryProvider.SHOW_ALL_CATEGORIES, - ); + this.fetchFunction = (options) => AddonModGlossary.getEntriesByCategory(glossaryId, options); + this.fetchInvalidate = () => AddonModGlossary.invalidateEntriesByCategory(glossaryId); break; case 'newest_first': // Newest first. this.viewMode = 'date'; - this.fetchFunction = (options) => AddonModGlossary.getEntriesByDate( - glossaryId, - 'CREATION', - 'DESC', - options, - ); - this.fetchInvalidate = () => AddonModGlossary.invalidateEntriesByDate( - glossaryId, - 'CREATION', - 'DESC', - ); + this.fetchFunction = (options) => AddonModGlossary.getEntriesByDate(glossaryId, 'CREATION', options); + this.fetchInvalidate = () => AddonModGlossary.invalidateEntriesByDate(glossaryId, 'CREATION'); break; case 'recently_updated': // Recently updated. this.viewMode = 'date'; - this.fetchFunction = (options) => AddonModGlossary.getEntriesByDate( - glossaryId, - 'UPDATE', - 'DESC', - options, - ); - this.fetchInvalidate = () => AddonModGlossary.invalidateEntriesByDate( - glossaryId, - 'UPDATE', - 'DESC', - ); + this.fetchFunction = (options) => AddonModGlossary.getEntriesByDate(glossaryId, 'UPDATE', options); + this.fetchInvalidate = () => AddonModGlossary.invalidateEntriesByDate(glossaryId, 'UPDATE'); break; case 'letter_all': @@ -286,15 +217,8 @@ export class AddonModGlossaryEntriesSource extends CoreRoutedItemsManagerSource< // Consider it is 'letter_all'. this.viewMode = 'letter'; this.fetchMode = 'letter_all'; - this.fetchFunction = (options) => AddonModGlossary.getEntriesByLetter( - glossaryId, - 'ALL', - options, - ); - this.fetchInvalidate = () => AddonModGlossary.invalidateEntriesByLetter( - glossaryId, - 'ALL', - ); + this.fetchFunction = (options) => AddonModGlossary.getEntriesByLetter(glossaryId, options); + this.fetchInvalidate = () => AddonModGlossary.invalidateEntriesByLetter(glossaryId); break; } } @@ -313,11 +237,10 @@ export class AddonModGlossaryEntriesSource extends CoreRoutedItemsManagerSource< const entries: AddonModGlossaryEntryItem[] = []; 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)); - entries.push(AddonModGlossaryEntriesSource.NEW_ENTRY); entries.push(...offlineEntries); } @@ -369,12 +292,7 @@ export class AddonModGlossaryEntriesSource extends CoreRoutedItemsManagerSource< /** * Type of items that can be held by the entries manager. */ -export type AddonModGlossaryEntryItem = AddonModGlossaryEntry | AddonModGlossaryOfflineEntry | AddonModGlossaryNewEntryForm; - -/** - * Type to select the new entry form. - */ -export type AddonModGlossaryNewEntryForm = { newEntry: true }; +export type AddonModGlossaryEntryItem = AddonModGlossaryEntry | AddonModGlossaryOfflineEntry; /** * Fetch mode to sort entries. diff --git a/src/addons/mod/glossary/classes/glossary-entries-swipe-manager.ts b/src/addons/mod/glossary/classes/glossary-entries-swipe-manager.ts deleted file mode 100644 index b1136068b..000000000 --- a/src/addons/mod/glossary/classes/glossary-entries-swipe-manager.ts +++ /dev/null @@ -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 { - - /** - * @inheritdoc - */ - protected skipItemInSwipe(item: AddonModGlossaryEntryItem): boolean { - return this.getSource().isNewEntryForm(item); - } - -} diff --git a/src/addons/mod/glossary/components/index/addon-mod-glossary-index.html b/src/addons/mod/glossary/components/index/addon-mod-glossary-index.html index 2413ed563..1198552e1 100644 --- a/src/addons/mod/glossary/components/index/addon-mod-glossary-index.html +++ b/src/addons/mod/glossary/components/index/addon-mod-glossary-index.html @@ -31,7 +31,7 @@ [componentId]="componentId" [courseId]="courseId" [hasDataToSync]="hasOffline" (completionChanged)="onCompletionChange()"> - +

{{ 'addon.mod_glossary.entriestobesynced' | translate }}

@@ -40,9 +40,12 @@ - - +
+ + + +
diff --git a/src/addons/mod/glossary/components/index/index.scss b/src/addons/mod/glossary/components/index/index.scss new file mode 100644 index 000000000..96c31cca1 --- /dev/null +++ b/src/addons/mod/glossary/components/index/index.scss @@ -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; + } + +} diff --git a/src/addons/mod/glossary/components/index/index.ts b/src/addons/mod/glossary/components/index/index.ts index 6b0acca1c..3ca1498ca 100644 --- a/src/addons/mod/glossary/components/index/index.ts +++ b/src/addons/mod/glossary/components/index/index.ts @@ -26,6 +26,7 @@ import { CoreRatingProvider } from '@features/rating/services/rating'; import { CoreRatingOffline } from '@features/rating/services/rating-offline'; import { CoreRatingSyncProvider } from '@features/rating/services/rating-sync'; import { IonContent } from '@ionic/angular'; +import { CoreNavigator } from '@services/navigator'; import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreTextUtils } from '@services/utils/text'; @@ -42,12 +43,15 @@ import { AddonModGlossaryEntryWithCategory, AddonModGlossaryGlossary, AddonModGlossaryProvider, + GLOSSARY_ENTRY_ADDED, + GLOSSARY_ENTRY_DELETED, + GLOSSARY_ENTRY_UPDATED, } from '../../services/glossary'; import { AddonModGlossaryOfflineEntry } from '../../services/glossary-offline'; import { - AddonModGlossaryAutoSyncData, - AddonModGlossarySyncProvider, + AddonModGlossaryAutoSyncedData, AddonModGlossarySyncResult, + GLOSSARY_AUTO_SYNCED, } from '../../services/glossary-sync'; import { AddonModGlossaryModuleHandlerService } from '../../services/handlers/module'; import { AddonModGlossaryPrefetchHandler } from '../../services/handlers/prefetch'; @@ -59,6 +63,7 @@ import { AddonModGlossaryModePickerPopoverComponent } from '../mode-picker/mode- @Component({ selector: 'addon-mod-glossary-index', templateUrl: 'addon-mod-glossary-index.html', + styleUrls: ['index.scss'], }) export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivityComponent implements OnInit, AfterViewInit, OnDestroy { @@ -75,13 +80,11 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity protected hasOfflineEntries = false; protected hasOfflineRatings = false; - protected syncEventName = AddonModGlossarySyncProvider.AUTO_SYNCED; - protected addEntryObserver?: CoreEventObserver; + protected syncEventName = GLOSSARY_AUTO_SYNCED; protected fetchedEntriesCanLoadMore = false; protected fetchedEntries: AddonModGlossaryEntry[] = []; protected sourceUnsubscribe?: () => void; - protected ratingOfflineObserver?: CoreEventObserver; - protected ratingSyncObserver?: CoreEventObserver; + protected observers?: CoreEventObserver[]; protected checkCompletionAfterLog = false; // Use CoreListItemsManager log system instead. getDivider?: (entry: AddonModGlossaryEntry) => string; @@ -136,30 +139,48 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity }); // When an entry is added, we reload the data. - this.addEntryObserver = CoreEvents.on(AddonModGlossaryProvider.ADD_ENTRY_EVENT, (data) => { - if (this.glossary && this.glossary.id === data.glossaryId) { - this.showLoadingAndRefresh(false); + this.observers = [ + CoreEvents.on(GLOSSARY_ENTRY_ADDED, ({ glossaryId }) => { + if (this.glossary?.id !== glossaryId) { + return; + } // Check completion since it could be configured to complete once the user adds a new entry. this.checkCompletion(); - } - }); + + this.showLoadingAndRefresh(false); + }), + CoreEvents.on(GLOSSARY_ENTRY_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. - 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' && data.instanceId == this.glossary.coursemodule) { this.hasOfflineRatings = 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' && data.instanceId == this.glossary.coursemodule) { this.hasOfflineRatings = false; this.hasOffline = this.hasOfflineEntries; } - }); + })); } /** @@ -227,7 +248,7 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity * @param syncEventData Data receiven on sync observer. * @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 && syncEventData.userId == CoreSites.getCurrentSiteUserId(); } @@ -388,7 +409,11 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity * Opens new entry editor. */ openNewEntry(): void { - this.entries?.select(AddonModGlossaryEntriesSource.NEW_ENTRY); + CoreNavigator.navigate( + this.splitView.outletActivated + ? '../new' + : './entry/new', + ); } /** @@ -410,9 +435,7 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity ngOnDestroy(): void { super.ngOnDestroy(); - this.addEntryObserver?.off(); - this.ratingOfflineObserver?.off(); - this.ratingSyncObserver?.off(); + this.observers?.forEach(observer => observer.off()); this.sourceUnsubscribe?.call(null); this.entries?.destroy(); } diff --git a/src/addons/mod/glossary/glossary-lazy.module.ts b/src/addons/mod/glossary/glossary-lazy.module.ts index c96ce6e7d..7d74f30f7 100644 --- a/src/addons/mod/glossary/glossary-lazy.module.ts +++ b/src/addons/mod/glossary/glossary-lazy.module.ts @@ -27,13 +27,9 @@ const mobileRoutes: Routes = [ component: AddonModGlossaryIndexPage, }, { - path: ':courseId/:cmId/entry/:entryId', + path: ':courseId/:cmId/entry/:entrySlug', loadChildren: () => import('./glossary-entry-lazy.module').then(m => m.AddonModGlossaryEntryLazyModule), }, - { - path: ':courseId/:cmId/edit/:timecreated', - loadChildren: () => import('./glossary-edit-lazy.module').then(m => m.AddonModGlossaryEditLazyModule), - }, ]; const tabletRoutes: Routes = [ @@ -42,18 +38,22 @@ const tabletRoutes: Routes = [ component: AddonModGlossaryIndexPage, children: [ { - path: 'entry/:entryId', + path: 'entry/:entrySlug', loadChildren: () => import('./glossary-entry-lazy.module').then(m => m.AddonModGlossaryEntryLazyModule), }, - { - path: 'edit/:timecreated', - loadChildren: () => import('./glossary-edit-lazy.module').then(m => m.AddonModGlossaryEditLazyModule), - }, ], }, ]; const routes: Routes = [ + { + path: ':courseId/:cmId/entry/new', + loadChildren: () => import('./glossary-edit-lazy.module').then(m => m.AddonModGlossaryEditLazyModule), + }, + { + path: ':courseId/:cmId/entry/:entrySlug/edit', + loadChildren: () => import('./glossary-edit-lazy.module').then(m => m.AddonModGlossaryEditLazyModule), + }, ...conditionalRoutes(mobileRoutes, () => CoreScreen.isMobile), ...conditionalRoutes(tabletRoutes, () => CoreScreen.isTablet), ]; diff --git a/src/addons/mod/glossary/glossary.module.ts b/src/addons/mod/glossary/glossary.module.ts index 82def7f99..da86adf1c 100644 --- a/src/addons/mod/glossary/glossary.module.ts +++ b/src/addons/mod/glossary/glossary.module.ts @@ -49,50 +49,40 @@ export const ADDON_MOD_GLOSSARY_SERVICES: Type[] = [ ]; const mainMenuRoutes: Routes = [ - { - path: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/entry/:entryId`, - loadChildren: () => import('./glossary-entry-lazy.module').then(m => m.AddonModGlossaryEntryLazyModule), - data: { swipeEnabled: false }, - }, - { - path: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/edit/:timecreated`, - loadChildren: () => import('./glossary-edit-lazy.module').then(m => m.AddonModGlossaryEditLazyModule), - data: { swipeEnabled: false }, - }, + // Course activity navigation. { path: AddonModGlossaryModuleHandlerService.PAGE_NAME, loadChildren: () => import('./glossary-lazy.module').then(m => m.AddonModGlossaryLazyModule), }, + + // Single Activity format navigation. + { + path: `${COURSE_CONTENTS_PATH}/${AddonModGlossaryModuleHandlerService.PAGE_NAME}/entry/new`, + loadChildren: () => import('./glossary-edit-lazy.module').then(m => m.AddonModGlossaryEditLazyModule), + data: { glossaryPathPrefix: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` }, + }, + { + 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( - [ - { - path: `${COURSE_CONTENTS_PATH}/${AddonModGlossaryModuleHandlerService.PAGE_NAME}/entry/:entryId`, - loadChildren: () => import('./glossary-entry-lazy.module').then(m => m.AddonModGlossaryEntryLazyModule), - data: { glossaryPathPrefix: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` }, - }, - { - path: `${COURSE_CONTENTS_PATH}/${AddonModGlossaryModuleHandlerService.PAGE_NAME}/edit/:timecreated`, - loadChildren: () => import('./glossary-edit-lazy.module').then(m => m.AddonModGlossaryEditLazyModule), - data: { glossaryPathPrefix: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` }, - }, - ], + [{ + path: `${COURSE_CONTENTS_PATH}/${AddonModGlossaryModuleHandlerService.PAGE_NAME}/entry/:entrySlug`, + loadChildren: () => import('./glossary-entry-lazy.module').then(m => m.AddonModGlossaryEntryLazyModule), + data: { glossaryPathPrefix: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` }, + }], () => CoreScreen.isMobile, ), ]; +// Single Activity format navigation. const courseContentsRoutes: Routes = conditionalRoutes( - [ - { - path: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/entry/:entryId`, - loadChildren: () => import('./glossary-entry-lazy.module').then(m => m.AddonModGlossaryEntryLazyModule), - data: { glossaryPathPrefix: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` }, - }, - { - path: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/edit/:timecreated`, - loadChildren: () => import('./glossary-edit-lazy.module').then(m => m.AddonModGlossaryEditLazyModule), - data: { glossaryPathPrefix: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` }, - }, - ], + [{ + path: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/entry/:entrySlug`, + loadChildren: () => import('./glossary-entry-lazy.module').then(m => m.AddonModGlossaryEntryLazyModule), + data: { glossaryPathPrefix: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` }, + }], () => CoreScreen.isTablet, ); diff --git a/src/addons/mod/glossary/lang.json b/src/addons/mod/glossary/lang.json index ba4329f33..c380778c6 100644 --- a/src/addons/mod/glossary/lang.json +++ b/src/addons/mod/glossary/lang.json @@ -1,6 +1,7 @@ { "addentry": "Add a new entry", "aliases": "Keyword(s)", + "areyousuredelete": "Are you sure you want to delete this entry?", "attachment": "Attachment", "browsemode": "Browse entries", "byalphabet": "Alphabetically", @@ -14,10 +15,15 @@ "categories": "Categories", "concept": "Concept", "definition": "Definition", + "deleteentry": "Delete entry", + "editentry": "Edit entry", "entriestobesynced": "Entries to be synced", + "entry": "Entry", + "entrydeleted": "Entry deleted", "entrypendingapproval": "This entry is pending approval.", "entryusedynalink": "This entry should be automatically linked", "errconceptalreadyexists": "This concept already exists. No duplicates allowed in this glossary.", + "errordeleting": "Error deleting entry.", "errorloadingentries": "An error occurred while loading entries.", "errorloadingentry": "An error occurred while loading the entry.", "errorloadingglossary": "An error occurred while loading the glossary.", diff --git a/src/addons/mod/glossary/pages/edit/edit.html b/src/addons/mod/glossary/pages/edit/edit.html index 8850c93d3..0af9d5629 100644 --- a/src/addons/mod/glossary/pages/edit/edit.html +++ b/src/addons/mod/glossary/pages/edit/edit.html @@ -11,12 +11,12 @@ - +
{{ 'addon.mod_glossary.concept' | translate }} - + @@ -31,7 +31,7 @@ {{ 'addon.mod_glossary.categories' | translate }} - @@ -39,11 +39,11 @@ - + {{ 'addon.mod_glossary.aliases' | translate }} - + @@ -51,7 +51,7 @@

{{ 'addon.mod_glossary.attachment' | translate }}

- @@ -62,19 +62,19 @@ {{ 'addon.mod_glossary.entryusedynalink' | translate }} - + {{ 'addon.mod_glossary.casesensitive' | translate }} - + {{ 'addon.mod_glossary.fullmatch' | translate }} - + - + {{ 'core.save' | translate }} diff --git a/src/addons/mod/glossary/pages/edit/edit.ts b/src/addons/mod/glossary/pages/edit/edit.ts index 334672856..e27fb967d 100644 --- a/src/addons/mod/glossary/pages/edit/edit.ts +++ b/src/addons/mod/glossary/pages/edit/edit.ts @@ -12,16 +12,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnInit, ViewChild, ElementRef, Optional, OnDestroy } from '@angular/core'; +import { Component, OnInit, ViewChild, ElementRef, Optional } from '@angular/core'; import { FormControl } from '@angular/forms'; -import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { CoreError } from '@classes/errors/error'; -import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; +import { CoreNetworkError } from '@classes/errors/network-error'; import { CoreSplitViewComponent } from '@components/split-view/split-view'; import { CoreFileUploader, CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader'; 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 { CoreNetwork } from '@services/network'; import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreTextUtils } from '@services/utils/text'; @@ -29,15 +30,12 @@ import { CoreUtils } from '@services/utils/utils'; import { Translate } from '@singletons'; import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreForms } from '@singletons/form'; -import { AddonModGlossaryEntriesSource } from '../../classes/glossary-entries-source'; -import { AddonModGlossaryEntriesSwipeManager } from '../../classes/glossary-entries-swipe-manager'; import { AddonModGlossary, AddonModGlossaryCategory, + AddonModGlossaryEntry, AddonModGlossaryEntryOption, AddonModGlossaryGlossary, - AddonModGlossaryNewEntry, - AddonModGlossaryNewEntryWithFiles, AddonModGlossaryProvider, } from '../../services/glossary'; import { AddonModGlossaryHelper } from '../../services/glossary-helper'; @@ -50,7 +48,7 @@ import { AddonModGlossaryOffline } from '../../services/glossary-offline'; selector: 'page-addon-mod-glossary-edit', templateUrl: 'edit.html', }) -export class AddonModGlossaryEditPage implements OnInit, OnDestroy, CanLeave { +export class AddonModGlossaryEditPage implements OnInit, CanLeave { @ViewChild('editFormEl') formElement?: ElementRef; @@ -59,32 +57,28 @@ export class AddonModGlossaryEditPage implements OnInit, OnDestroy, CanLeave { courseId!: number; loaded = false; glossary?: AddonModGlossaryGlossary; - attachments: FileEntry[] = []; definitionControl = new FormControl(); categories: AddonModGlossaryCategory[] = []; + showAliases = true; editorExtraParams: Record = {}; - entry: AddonModGlossaryNewEntry = { + handler!: AddonModGlossaryFormHandler; + data: AddonModGlossaryFormData = { concept: '', definition: '', timecreated: 0, - }; - - entries?: AddonModGlossaryEditEntriesSwipeManager; - - options = { - categories: [], + attachments: [], + categories: [], aliases: '', usedynalink: false, casesensitive: false, fullmatch: false, }; - protected timecreated!: number; - protected concept = ''; + originalData?: AddonModGlossaryFormData; + protected syncId?: string; protected syncObserver?: CoreEventObserver; protected isDestroyed = false; - protected originalData?: AddonModGlossaryNewEntryWithFiles; protected saved = false; constructor(protected route: ActivatedRoute, @Optional() protected splitView: CoreSplitViewComponent) {} @@ -94,22 +88,21 @@ export class AddonModGlossaryEditPage implements OnInit, OnDestroy, CanLeave { */ async ngOnInit(): Promise { try { - const routeData = this.route.snapshot.data; + const entrySlug = CoreNavigator.getRouteParam('entrySlug'); this.cmId = CoreNavigator.getRequiredRouteNumberParam('cmId'); this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); - this.timecreated = CoreNavigator.getRequiredRouteNumberParam('timecreated'); - this.concept = CoreNavigator.getRouteParam('concept') || ''; - this.editorExtraParams.timecreated = this.timecreated; - if (this.timecreated !== 0 && (routeData.swipeEnabled ?? true)) { - const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource( - AddonModGlossaryEntriesSource, - [this.courseId, this.cmId, routeData.glossaryPathPrefix ?? ''], - ); + if (entrySlug?.startsWith('new-')) { + const timecreated = Number(entrySlug.slice(4)); + this.editorExtraParams.timecreated = timecreated; + this.handler = new AddonModGlossaryOfflineFormHandler(this, timecreated); + } else if (entrySlug) { + const { entry } = await AddonModGlossary.getEntry(Number(entrySlug)); - this.entries = new AddonModGlossaryEditEntriesSwipeManager(source); - - await this.entries.start(); + this.editorExtraParams.timecreated = entry.timecreated; + this.handler = new AddonModGlossaryOnlineFormHandler(this, entry); + } else { + this.handler = new AddonModGlossaryNewFormHandler(this); } } catch (error) { CoreDomUtils.showErrorModal(error); @@ -122,13 +115,6 @@ export class AddonModGlossaryEditPage implements OnInit, OnDestroy, CanLeave { this.fetchData(); } - /** - * @inheritdoc - */ - ngOnDestroy(): void { - this.entries?.destroy(); - } - /** * Fetch required data. * @@ -138,13 +124,7 @@ export class AddonModGlossaryEditPage implements OnInit, OnDestroy, CanLeave { try { this.glossary = await AddonModGlossary.getGlossary(this.courseId, this.cmId); - if (this.timecreated > 0) { - await this.loadOfflineData(); - } - - this.categories = await AddonModGlossary.getAllCategories(this.glossary.id, { - cmId: this.cmId, - }); + await this.handler.loadData(this.glossary); this.loaded = true; } 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 { - 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 && ( entry.options.categories).split(',')) || []; - this.options.aliases = 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. */ protected resetForm(): void { - this.entry.concept = ''; - this.entry.definition = ''; - this.entry.timecreated = 0; this.originalData = undefined; - this.options.categories = []; - this.options.aliases = ''; - this.options.usedynalink = false; - this.options.casesensitive = false; - this.options.fullmatch = false; - this.attachments.length = 0; // Empty the array. + this.data.concept = ''; + this.data.definition = ''; + this.data.timecreated = 0; + this.data.categories = []; + this.data.aliases = ''; + this.data.usedynalink = false; + this.data.casesensitive = false; + this.data.fullmatch = false; + this.data.attachments.length = 0; // Empty the array. this.definitionControl.setValue(''); } @@ -222,7 +159,7 @@ export class AddonModGlossaryEditPage implements OnInit, OnDestroy, CanLeave { * @param text The new text. */ 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; } - if (AddonModGlossaryHelper.hasEntryDataChanged(this.entry, this.attachments, this.originalData)) { + if (this.hasDataChanged()) { // Show confirmation if some data has been modified. await CoreDomUtils.showConfirm(Translate.instant('core.confirmcanceledit')); } // Delete the local files from the tmp folder. - CoreFileUploader.clearTmpFiles(this.attachments); + CoreFileUploader.clearTmpFiles(this.data.attachments); CoreForms.triggerFormCancelledEvent(this.formElement, CoreSites.getCurrentSiteId()); @@ -252,114 +189,26 @@ export class AddonModGlossaryEditPage implements OnInit, OnDestroy, CanLeave { * Save the entry. */ async save(): Promise { - let definition = this.entry.definition; - let entryId: number | undefined; - const timecreated = this.entry.timecreated || Date.now(); - - if (!this.entry.concept || !definition) { + if (!this.data.concept || !this.data.definition) { CoreDomUtils.showErrorModal('addon.mod_glossary.fillfields', true); return; } + if (!this.glossary) { + return; + } + const modal = await CoreDomUtils.showModalLoading('core.sending', true); - definition = CoreTextUtils.formatHtmlLines(definition); try { - if (!this.glossary) { - return; - } + const savedOnline = await this.handler.save(this.glossary); - // Upload attachments first if any. - const { saveOffline, attachmentsResult } = await this.uploadAttachments(timecreated); + this.saved = true; - const options: Record = { - aliases: this.options.aliases, - categories: this.options.categories.join(','), - }; + CoreForms.triggerFormSubmittedEvent(this.formElement, savedOnline, CoreSites.getCurrentSiteId()); - if (this.glossary.usedynalink) { - 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, - 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(); - } + this.goBack(); } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'addon.mod_glossary.cannoteditentry', true); } 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 Promise resolved when done. + * @returns True if data has changed, false otherwise. */ - protected async uploadAttachments( - timecreated: number, - ): Promise<{saveOffline: boolean; attachmentsResult?: number | CoreFileUploaderStoreFilesResult}> { - if (!this.attachments.length || !this.glossary) { - return { - saveOffline: false, - }; + protected hasDataChanged(): boolean { + if (!this.originalData || this.originalData.concept === undefined) { + // There is no original data. + return !!(this.data.definition || this.data.concept || this.data.attachments.length > 0); } - try { - const attachmentsResult = await CoreFileUploader.uploadOrReuploadFiles( - 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, - }; + if (this.originalData.definition != this.data.definition || this.originalData.concept != this.data.concept) { + return true; } + + 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; + + /** + * Save form data. + * + * @param glossary Glossary. + * @returns Whether the form was saved online. + */ + abstract save(glossary: AddonModGlossaryGlossary): Promise; + + /** + * Load form categories. + * + * @param glossary Glossary. + */ + protected async loadCategories(glossary: AddonModGlossaryGlossary): Promise { + 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 { + 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 { + 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 { + 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 { + const data = this.page.data; + const options: Record = {}; + + 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 */ - protected getSelectedItemPathFromRoute(route: ActivatedRouteSnapshot): string | null { - return `${this.getSource().GLOSSARY_PATH_PREFIX}edit/${route.params.timecreated}`; + async loadData(glossary: AddonModGlossaryGlossary): Promise { + 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 { + 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 { + 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 { + await this.loadCategories(glossary); + } + + /** + * @inheritdoc + */ + async save(glossary: AddonModGlossaryGlossary): Promise { + 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 { + 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 { + 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 { + 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 { + 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; +}; diff --git a/src/addons/mod/glossary/pages/entry/entry.html b/src/addons/mod/glossary/pages/entry/entry.html index ee112af6e..aa50fb45b 100644 --- a/src/addons/mod/glossary/pages/entry/entry.html +++ b/src/addons/mod/glossary/pages/entry/entry.html @@ -18,6 +18,12 @@ + + + + {{ 'core.hasdatatosync' | translate: { $a: 'addon.mod_glossary.entry' | translate } }} + + @@ -26,9 +32,9 @@ [courseId]="courseId"> -

{{ entry.userfullname }}

+

{{ onlineEntry.userfullname }}

- {{ entry.timemodified | coreDateDayOrTime }} + {{ onlineEntry.timemodified | coreDateDayOrTime }}
@@ -37,7 +43,7 @@

- {{ entry.timemodified | coreDateDayOrTime }} + {{ onlineEntry.timemodified | coreDateDayOrTime }}
@@ -46,32 +52,53 @@ -
- + +
+ + + + + + +
+
+
+
- +
+ + +
+
+ + +
+
{{ 'core.tag.tags' | translate }}:
- +
- +

{{ 'addon.mod_glossary.entrypendingapproval' | translate }}

- + - - + diff --git a/src/addons/mod/glossary/pages/entry/entry.ts b/src/addons/mod/glossary/pages/entry/entry.ts index f4ee71044..ccbcedea7 100644 --- a/src/addons/mod/glossary/pages/entry/entry.ts +++ b/src/addons/mod/glossary/pages/entry/entry.ts @@ -12,24 +12,32 @@ // See the License for the specific language governing permissions and // 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 { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; +import { CoreSwipeNavigationItemsManager } from '@classes/items-management/swipe-navigation-items-manager'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; import { CoreCommentsCommentsComponent } from '@features/comments/components/comments/comments'; import { CoreComments } from '@features/comments/services/comments'; import { CoreRatingInfo } from '@features/rating/services/rating'; import { CoreTag } from '@features/tag/services/tag'; +import { FileEntry } from '@ionic-native/file/ngx'; import { IonRefresher } from '@ionic/angular'; 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 { AddonModGlossaryEntriesSource } from '../../classes/glossary-entries-source'; -import { AddonModGlossaryEntriesSwipeManager } from '../../classes/glossary-entries-swipe-manager'; +import { Translate } from '@singletons'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { AddonModGlossaryEntriesSource, AddonModGlossaryEntryItem } from '../../classes/glossary-entries-source'; import { AddonModGlossary, AddonModGlossaryEntry, AddonModGlossaryGlossary, AddonModGlossaryProvider, + GLOSSARY_ENTRY_UPDATED, } from '../../services/glossary'; /** @@ -45,62 +53,90 @@ export class AddonModGlossaryEntryPage implements OnInit, OnDestroy { component = AddonModGlossaryProvider.COMPONENT; componentId?: number; - entry?: AddonModGlossaryEntry; - entries?: AddonModGlossaryEntryEntriesSwipeManager; + onlineEntry?: AddonModGlossaryEntry; + offlineEntry?: AddonModGlossaryOfflineEntry; + offlineEntryFiles?: FileEntry[]; + entries!: AddonModGlossaryEntryEntriesSwipeManager; glossary?: AddonModGlossaryGlossary; + entryUpdatedObserver?: CoreEventObserver; loaded = false; showAuthor = false; showDate = false; ratingInfo?: CoreRatingInfo; tagsEnabled = false; + canEdit = false; + canDelete = false; commentsEnabled = false; 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 */ async ngOnInit(): Promise { + let onlineEntryId: number | null = null; + let offlineEntryTimeCreated: number | null = null; + try { - const routeData = this.route.snapshot.data; this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); - this.entryId = CoreNavigator.getRequiredRouteNumberParam('entryId'); this.tagsEnabled = CoreTag.areTagsAvailableInSite(); this.commentsEnabled = !CoreComments.areCommentsDisabledInSite(); + this.cmId = CoreNavigator.getRequiredRouteNumberParam('cmId'); - if (routeData.swipeEnabled ?? true) { - this.cmId = CoreNavigator.getRequiredRouteNumberParam('cmId'); - const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource( - AddonModGlossaryEntriesSource, - [this.courseId, this.cmId, routeData.glossaryPathPrefix ?? ''], - ); + const entrySlug = CoreNavigator.getRequiredRouteParam('entrySlug'); + const routeData = this.route.snapshot.data; + const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource( + AddonModGlossaryEntriesSource, + [this.courseId, this.cmId, routeData.glossaryPathPrefix ?? ''], + ); - this.entries = new AddonModGlossaryEntryEntriesSwipeManager(source); + this.entries = new AddonModGlossaryEntryEntriesSwipeManager(source); - await this.entries.start(); + await this.entries.start(); + + if (entrySlug.startsWith('new-')) { + offlineEntryTimeCreated = Number(entrySlug.slice(4)); } else { - this.cmId = CoreNavigator.getRouteNumberParam('cmId'); + onlineEntryId = Number(entrySlug); } } catch (error) { CoreDomUtils.showErrorModal(error); - CoreNavigator.back(); return; } - try { - await this.fetchEntry(); - - if (!this.glossary || !this.componentId) { + this.entryUpdatedObserver = CoreEvents.on(GLOSSARY_ENTRY_UPDATED, data => { + if (data.glossaryId !== this.glossary?.id) { 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 { this.loaded = true; } @@ -110,7 +146,66 @@ export class AddonModGlossaryEntryPage implements OnInit, OnDestroy { * @inheritdoc */ ngOnDestroy(): void { - this.entries?.destroy(); + this.entries.destroy(); + this.entryUpdatedObserver?.off(); + } + + /** + * Edit entry. + */ + async editEntry(): Promise { + await CoreNavigator.navigate('./edit'); + } + + /** + * Delete entry. + */ + async deleteEntry(): Promise { + 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. */ async doRefresh(refresher?: IonRefresher): Promise { - if (this.glossary?.allowcomments && this.entry && this.entry.id > 0 && this.commentsEnabled && this.comments) { - // Refresh comments. Don't add it to promises because we don't want the comments fetch to block the entry fetch. + if (this.onlineEntry && this.glossary?.allowcomments && this.onlineEntry.id > 0 && this.commentsEnabled && this.comments) { + // Refresh comments asynchronously (without blocking the current promise). CoreUtils.ignoreErrors(this.comments.doRefresh()); } try { - await CoreUtils.ignoreErrors(AddonModGlossary.invalidateEntry(this.entryId)); + if (this.onlineEntry) { + await CoreUtils.ignoreErrors(AddonModGlossary.invalidateEntry(this.onlineEntry.id)); + await this.loadOnlineEntry(this.onlineEntry.id); + } else if (this.offlineEntry) { + const entrySlug = CoreNavigator.getRequiredRouteParam('entrySlug'); + const timecreated = Number(entrySlug.slice(4)); - await this.fetchEntry(); + await this.loadOfflineEntry(timecreated); + } } finally { refresher?.complete(); } } /** - * Convenience function to get the glossary entry. - * - * @returns Promise resolved when done. + * Load online entry data. */ - protected async fetchEntry(): Promise { + protected async loadOnlineEntry(entryId: number): Promise { 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.canDelete = canDeleteEntries && !!result.permissions?.candelete; + this.canEdit = canUpdateEntries && !!result.permissions?.canupdate; - if (this.glossary) { - // Glossary already loaded, nothing else to load. - return; - } - - // Load the glossary. - this.glossary = await AddonModGlossary.getGlossaryById(this.courseId, this.entry.glossaryid); - this.componentId = this.glossary.coursemodule; - - switch (this.glossary.displayformat) { - case 'fullwithauthor': - case 'encyclopedia': - this.showAuthor = true; - this.showDate = true; - break; - case 'fullwithoutauthor': - this.showAuthor = false; - this.showDate = true; - break; - default: // Default, and faq, simple, entrylist, continuous. - this.showAuthor = false; - this.showDate = false; - } + await this.loadGlossary(); } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'addon.mod_glossary.errorloadingentry', true); } } + /** + * Load offline entry data. + * + * @param timecreated Entry Timecreated. + */ + protected async loadOfflineEntry(timecreated: number): Promise { + 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 { + if (this.glossary) { + return this.glossary; + } + + this.glossary = await AddonModGlossary.getGlossary(this.courseId, this.cmId); + this.componentId = this.glossary.coursemodule; + + switch (this.glossary.displayformat) { + case 'fullwithauthor': + case 'encyclopedia': + this.showAuthor = true; + this.showDate = true; + break; + case 'fullwithoutauthor': + this.showAuthor = false; + this.showDate = true; + break; + default: // Default, and faq, simple, entrylist, continuous. + this.showAuthor = false; + this.showDate = false; + } + + return this.glossary; + } + /** * Function called when rating is updated online. */ ratingUpdated(): void { - AddonModGlossary.invalidateEntry(this.entryId); + if (!this.onlineEntry) { + return; + } + + AddonModGlossary.invalidateEntry(this.onlineEntry.id); } } @@ -186,13 +326,14 @@ export class AddonModGlossaryEntryPage implements OnInit, OnDestroy { /** * Helper to manage swiping within a collection of glossary entries. */ -class AddonModGlossaryEntryEntriesSwipeManager extends AddonModGlossaryEntriesSwipeManager { +class AddonModGlossaryEntryEntriesSwipeManager + extends CoreSwipeNavigationItemsManager { /** * @inheritdoc */ protected getSelectedItemPathFromRoute(route: ActivatedRouteSnapshot): string | null { - return `${this.getSource().GLOSSARY_PATH_PREFIX}entry/${route.params.entryId}`; + return `${this.getSource().GLOSSARY_PATH_PREFIX}entry/${route.params.entrySlug}`; } } diff --git a/src/addons/mod/glossary/services/glossary-helper.ts b/src/addons/mod/glossary/services/glossary-helper.ts index d51c457d2..46e4e25ca 100644 --- a/src/addons/mod/glossary/services/glossary-helper.ts +++ b/src/addons/mod/glossary/services/glossary-helper.ts @@ -18,7 +18,6 @@ import { CoreFileUploader, CoreFileUploaderStoreFilesResult } from '@features/fi import { CoreFile } from '@services/file'; import { CoreUtils } from '@services/utils/utils'; import { AddonModGlossaryOffline } from './glossary-offline'; -import { AddonModGlossaryNewEntry, AddonModGlossaryNewEntryWithFiles } from './glossary'; import { makeSingleton } from '@singletons'; import { CoreFileEntry } from '@services/file-helper'; @@ -58,31 +57,6 @@ export class AddonModGlossaryHelperProvider { 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 * to be submitted later. diff --git a/src/addons/mod/glossary/services/glossary-offline.ts b/src/addons/mod/glossary/services/glossary-offline.ts index a0dadeee1..cc4b89c86 100644 --- a/src/addons/mod/glossary/services/glossary-offline.ts +++ b/src/addons/mod/glossary/services/glossary-offline.ts @@ -17,11 +17,11 @@ import { CoreFileUploaderStoreFilesResult } from '@features/fileuploader/service import { CoreFile } from '@services/file'; import { CoreSites } from '@services/sites'; import { CoreTextUtils } from '@services/utils/text'; -import { CoreUtils } from '@services/utils/utils'; import { makeSingleton } from '@singletons'; +import { CoreEvents } from '@singletons/events'; import { CorePath } from '@singletons/path'; 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. @@ -30,33 +30,33 @@ import { AddonModGlossaryDiscardedEntry, AddonModGlossaryEntryOption } from './g export class AddonModGlossaryOfflineProvider { /** - * Delete a new entry. + * Delete an offline entry. * * @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. * @returns Promise resolved if deleted, rejected if failure. */ - async deleteNewEntry(glossaryId: number, concept: string, timeCreated: number, siteId?: string): Promise { + async deleteOfflineEntry(glossaryId: number, timecreated: number, siteId?: string): Promise { const site = await CoreSites.getSite(siteId); const conditions: Partial = { glossaryid: glossaryId, - concept: concept, - timecreated: timeCreated, + timecreated: timecreated, }; 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. * @returns Promise resolved with entries. */ - async getAllNewEntries(siteId?: string): Promise { + async getAllOfflineEntries(siteId?: string): Promise { const site = await CoreSites.getSite(siteId); const records = await site.getDb().getRecords(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 concept Glossary entry concept. * @param timeCreated The time the entry was created. * @param siteId Site ID. If not defined, current site. * @returns Promise resolved with entry. */ - async getNewEntry( + async getOfflineEntry( glossaryId: number, - concept: string, timeCreated: number, siteId?: string, ): Promise { @@ -83,7 +81,6 @@ export class AddonModGlossaryOfflineProvider { const conditions: Partial = { glossaryid: glossaryId, - concept: concept, timecreated: timeCreated, }; @@ -100,7 +97,7 @@ export class AddonModGlossaryOfflineProvider { * @param userId User the entries belong to. If not defined, current user in site. * @returns Promise resolved with entries. */ - async getGlossaryNewEntries(glossaryId: number, siteId?: string, userId?: number): Promise { + async getGlossaryOfflineEntries(glossaryId: number, siteId?: string, userId?: number): Promise { const site = await CoreSites.getSite(siteId); const conditions: Partial = { @@ -143,7 +140,7 @@ export class AddonModGlossaryOfflineProvider { } // 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 { // No offline data found, 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 concept Glossary entry concept. * @param definition Glossary entry concept definition. * @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 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 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. */ - async addNewEntry( + async addOfflineEntry( glossaryId: number, concept: string, definition: string, courseId: number, + timecreated: number, options?: Record, attachments?: CoreFileUploaderStoreFilesResult, - timeCreated?: number, siteId?: string, userId?: number, - discardEntry?: AddonModGlossaryDiscardedEntry, ): Promise { const site = await CoreSites.getSite(siteId); @@ -188,19 +183,52 @@ export class AddonModGlossaryOfflineProvider { options: JSON.stringify(options || {}), attachments: JSON.stringify(attachments), 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); + CoreEvents.trigger(GLOSSARY_ENTRY_ADDED, { glossaryId, timecreated }, siteId); + 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, + attachments?: CoreFileUploaderStoreFilesResult, + ): Promise { + const site = await CoreSites.getSite(); + const entry: Omit = { + 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. * @@ -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 concept The name of the entry. diff --git a/src/addons/mod/glossary/services/glossary-sync.ts b/src/addons/mod/glossary/services/glossary-sync.ts index 0fc65ad13..b922d7262 100644 --- a/src/addons/mod/glossary/services/glossary-sync.ts +++ b/src/addons/mod/glossary/services/glossary-sync.ts @@ -31,14 +31,14 @@ import { AddonModGlossaryOffline, AddonModGlossaryOfflineEntry } from './glossar import { CoreFileUploader } from '@features/fileuploader/services/fileuploader'; import { CoreFileEntry } from '@services/file-helper'; +export const GLOSSARY_AUTO_SYNCED = 'addon_mod_glossary_auto_synced'; + /** * Service to sync glossaries. */ @Injectable({ providedIn: 'root' }) export class AddonModGlossarySyncProvider extends CoreCourseActivitySyncBaseProvider { - static readonly AUTO_SYNCED = 'addon_mod_glossary_autom_synced'; - protected componentTranslatableString = 'glossary'; constructor() { @@ -50,10 +50,9 @@ export class AddonModGlossarySyncProvider extends CoreCourseActivitySyncBaseProv * * @param siteId Site ID to sync. If not defined, sync all sites. * @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 { - return this.syncOnSites('all glossaries', (siteId) => this.syncAllGlossariesFunc(!!force, siteId), siteId); + async syncAllGlossaries(siteId?: string, force?: boolean): Promise { + 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 siteId Site ID to sync. - * @returns Promise resolved if sync is successful, rejected if sync fails. */ protected async syncAllGlossariesFunc(force: boolean, siteId: string): Promise { 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 siteId Site ID to sync. - * @returns Promise resolved if sync is successful, rejected if sync fails. */ protected async syncAllGlossariesEntries(force: boolean, siteId: string): Promise { - const entries = await AddonModGlossaryOffline.getAllNewEntries(siteId); + const entries = await AddonModGlossaryOffline.getAllOfflineEntries(siteId); // Do not sync same glossary twice. const treated: Record = {}; @@ -98,7 +95,7 @@ export class AddonModGlossarySyncProvider extends CoreCourseActivitySyncBaseProv if (result?.updated) { // Sync successful, send event. - CoreEvents.trigger(AddonModGlossarySyncProvider.AUTO_SYNCED, { + CoreEvents.trigger(GLOSSARY_AUTO_SYNCED, { glossaryId: entry.glossaryid, userId: entry.userid, warnings: result.warnings, @@ -180,7 +177,7 @@ export class AddonModGlossarySyncProvider extends CoreCourseActivitySyncBaseProv // Get offline responses to be sent. const entries = await CoreUtils.ignoreErrors( - AddonModGlossaryOffline.getGlossaryNewEntries(glossaryId, siteId, userId), + AddonModGlossaryOffline.getGlossaryOfflineEntries(glossaryId, siteId, userId), [], ); @@ -285,11 +282,10 @@ export class AddonModGlossarySyncProvider extends CoreCourseActivitySyncBaseProv * @param concept Glossary entry concept. * @param timeCreated Time to allow duplicated entries. * @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 { await Promise.all([ - AddonModGlossaryOffline.deleteNewEntry(glossaryId, concept, timeCreated, siteId), + AddonModGlossaryOffline.deleteOfflineEntry(glossaryId, timeCreated, siteId), AddonModGlossaryHelper.deleteStoredFiles(glossaryId, concept, timeCreated, siteId), ]); } @@ -341,15 +337,28 @@ export class AddonModGlossarySyncProvider extends CoreCourseActivitySyncBaseProv 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. */ 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; userId: number; warnings: string[]; diff --git a/src/addons/mod/glossary/services/glossary.ts b/src/addons/mod/glossary/services/glossary.ts index 69645bbee..487544639 100644 --- a/src/addons/mod/glossary/services/glossary.ts +++ b/src/addons/mod/glossary/services/glossary.ts @@ -25,12 +25,13 @@ import { CoreSites, CoreSitesCommonWSOptions, CoreSitesReadingStrategy } from '@ import { CoreUtils } from '@services/utils/utils'; import { CoreWSExternalFile, CoreWSExternalWarning } from '@services/ws'; import { makeSingleton, Translate } from '@singletons'; +import { CoreEvents } from '@singletons/events'; import { AddonModGlossaryEntryDBRecord, ENTRIES_TABLE_NAME } from './database/glossary'; 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. @@ -41,10 +42,9 @@ export class AddonModGlossaryProvider { static readonly COMPONENT = 'mmaModGlossary'; static readonly LIMIT_ENTRIES = 25; 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. @@ -53,7 +53,7 @@ export class AddonModGlossaryProvider { * @returns Cache key. */ 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 siteId Site ID. If not defined, current site. - * @returns Resolved when data is invalidated. */ async invalidateCourseGlossaries(courseId: number, siteId?: string): Promise { const site = await CoreSites.getSite(siteId); @@ -104,44 +103,35 @@ export class AddonModGlossaryProvider { * Get the entries by author cache key. * * @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. */ - protected getEntriesByAuthorCacheKey(glossaryId: number, letter: string, field: string, sort: string): string { - return ROOT_CACHE_KEY + 'entriesByAuthor:' + glossaryId + ':' + letter + ':' + field + ':' + sort; + protected getEntriesByAuthorCacheKey(glossaryId: number): string { + return `${AddonModGlossaryProvider.ROOT_CACHE_KEY}entriesByAuthor:${glossaryId}:ALL:LASTNAME:ASC`; } /** * Get entries by author. * * @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. * @returns Resolved with the entries. */ async getEntriesByAuthor( glossaryId: number, - letter: string, - field: string, - sort: string, options: AddonModGlossaryGetEntriesOptions = {}, ): Promise { const site = await CoreSites.getSite(options.siteId); const params: AddonModGlossaryGetEntriesByAuthorWSParams = { id: glossaryId, - letter: letter, - field: field, - sort: sort, + letter: 'ALL', + field: 'LASTNAME', + sort: 'ASC', from: options.from || 0, limit: options.limit || AddonModGlossaryProvider.LIMIT_ENTRIES, }; const preSets: CoreSiteWSPreSets = { - cacheKey: this.getEntriesByAuthorCacheKey(glossaryId, letter, field, sort), + cacheKey: this.getEntriesByAuthorCacheKey(glossaryId), updateFrequency: CoreSite.FREQUENCY_SOMETIMES, component: AddonModGlossaryProvider.COMPONENT, componentId: options.cmId, @@ -155,22 +145,12 @@ export class AddonModGlossaryProvider { * Invalidate cache of entries by author. * * @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. - * @returns Resolved when data is invalidated. */ - async invalidateEntriesByAuthor( - glossaryId: number, - letter: string, - field: string, - sort: string, - siteId?: string, - ): Promise { + async invalidateEntriesByAuthor(glossaryId: number, siteId?: string): Promise { const site = await CoreSites.getSite(siteId); - const key = this.getEntriesByAuthorCacheKey(glossaryId, letter, field, sort); + const key = this.getEntriesByAuthorCacheKey(glossaryId); await site.invalidateWsCacheForKey(key); } @@ -179,26 +159,23 @@ export class AddonModGlossaryProvider { * Get entries by category. * * @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. * @returns Resolved with the entries. */ async getEntriesByCategory( glossaryId: number, - categoryId: number, options: AddonModGlossaryGetEntriesOptions = {}, ): Promise { const site = await CoreSites.getSite(options.siteId); const params: AddonModGlossaryGetEntriesByCategoryWSParams = { id: glossaryId, - categoryid: categoryId, + categoryid: AddonModGlossaryProvider.SHOW_ALL_CATEGORIES, from: options.from || 0, limit: options.limit || AddonModGlossaryProvider.LIMIT_ENTRIES, }; const preSets: CoreSiteWSPreSets = { - cacheKey: this.getEntriesByCategoryCacheKey(glossaryId, categoryId), + cacheKey: this.getEntriesByCategoryCacheKey(glossaryId), updateFrequency: CoreSite.FREQUENCY_SOMETIMES, component: AddonModGlossaryProvider.COMPONENT, componentId: options.cmId, @@ -212,15 +189,12 @@ export class AddonModGlossaryProvider { * Invalidate cache of entries by category. * * @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. - * @returns Resolved when data is invalidated. */ - async invalidateEntriesByCategory(glossaryId: number, categoryId: number, siteId?: string): Promise { + async invalidateEntriesByCategory(glossaryId: number, siteId?: string): Promise { const site = await CoreSites.getSite(siteId); - const key = this.getEntriesByCategoryCacheKey(glossaryId, categoryId); + const key = this.getEntriesByCategoryCacheKey(glossaryId); await site.invalidateWsCacheForKey(key); } @@ -229,12 +203,12 @@ export class AddonModGlossaryProvider { * Get the entries by category cache key. * * @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. */ - getEntriesByCategoryCacheKey(glossaryId: number, categoryId: number): string { - return ROOT_CACHE_KEY + 'entriesByCategory:' + glossaryId + ':' + categoryId; + getEntriesByCategoryCacheKey(glossaryId: number): string { + 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 order The way to order the records. - * @param sort The direction of the order. * @returns Cache key. */ - getEntriesByDateCacheKey(glossaryId: number, order: string, sort: string): string { - return ROOT_CACHE_KEY + 'entriesByDate:' + glossaryId + ':' + order + ':' + sort; + getEntriesByDateCacheKey(glossaryId: number, order: string): string { + return `${AddonModGlossaryProvider.ROOT_CACHE_KEY}entriesByDate:${glossaryId}:${order}:DESC`; } /** @@ -254,14 +227,12 @@ export class AddonModGlossaryProvider { * * @param glossaryId Glossary Id. * @param order The way to order the records. - * @param sort The direction of the order. * @param options Other options. * @returns Resolved with the entries. */ async getEntriesByDate( glossaryId: number, order: string, - sort: string, options: AddonModGlossaryGetEntriesOptions = {}, ): Promise { const site = await CoreSites.getSite(options.siteId); @@ -269,12 +240,12 @@ export class AddonModGlossaryProvider { const params: AddonModGlossaryGetEntriesByDateWSParams = { id: glossaryId, order: order, - sort: sort, + sort: 'DESC', from: options.from || 0, limit: options.limit || AddonModGlossaryProvider.LIMIT_ENTRIES, }; const preSets: CoreSiteWSPreSets = { - cacheKey: this.getEntriesByDateCacheKey(glossaryId, order, sort), + cacheKey: this.getEntriesByDateCacheKey(glossaryId, order), updateFrequency: CoreSite.FREQUENCY_SOMETIMES, component: AddonModGlossaryProvider.COMPONENT, componentId: options.cmId, @@ -289,14 +260,12 @@ export class AddonModGlossaryProvider { * * @param glossaryId Glossary Id. * @param order The way to order the records. - * @param sort The direction of the order. * @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 { + async invalidateEntriesByDate(glossaryId: number, order: string, siteId?: string): Promise { const site = await CoreSites.getSite(siteId); - const key = this.getEntriesByDateCacheKey(glossaryId, order, sort); + const key = this.getEntriesByDateCacheKey(glossaryId, order); await site.invalidateWsCacheForKey(key); } @@ -305,24 +274,21 @@ export class AddonModGlossaryProvider { * Get the entries by letter cache key. * * @param glossaryId Glossary Id. - * @param letter A letter, or a special keyword. * @returns Cache key. */ - protected getEntriesByLetterCacheKey(glossaryId: number, letter: string): string { - return ROOT_CACHE_KEY + 'entriesByLetter:' + glossaryId + ':' + letter; + protected getEntriesByLetterCacheKey(glossaryId: number): string { + return `${AddonModGlossaryProvider.ROOT_CACHE_KEY}entriesByLetter:${glossaryId}:ALL`; } /** * Get entries by letter. * * @param glossaryId Glossary Id. - * @param letter A letter, or a special keyword. * @param options Other options. * @returns Resolved with the entries. */ async getEntriesByLetter( glossaryId: number, - letter: string, options: AddonModGlossaryGetEntriesOptions = {}, ): Promise { options.from = options.from || 0; @@ -332,12 +298,12 @@ export class AddonModGlossaryProvider { const params: AddonModGlossaryGetEntriesByLetterWSParams = { id: glossaryId, - letter: letter, + letter: 'ALL', from: options.from, limit: options.limit, }; const preSets: CoreSiteWSPreSets = { - cacheKey: this.getEntriesByLetterCacheKey(glossaryId, letter), + cacheKey: this.getEntriesByLetterCacheKey(glossaryId), updateFrequency: CoreSite.FREQUENCY_SOMETIMES, component: AddonModGlossaryProvider.COMPONENT, componentId: options.cmId, @@ -362,16 +328,14 @@ export class AddonModGlossaryProvider { * Invalidate cache of entries by letter. * * @param glossaryId Glossary Id. - * @param letter A letter, or a special keyword. * @param siteId Site ID. If not defined, current site. - * @returns Resolved when data is invalidated. */ - async invalidateEntriesByLetter(glossaryId: number, letter: string, siteId?: string): Promise { + async invalidateEntriesByLetter(glossaryId: number, siteId?: string): Promise { 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 query The search query. * @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. */ - protected getEntriesBySearchCacheKey( - glossaryId: number, - query: string, - fullSearch: boolean, - order: string, - sort: string, - ): string { - return ROOT_CACHE_KEY + 'entriesBySearch:' + glossaryId + ':' + fullSearch + ':' + order + ':' + sort + ':' + query; + protected getEntriesBySearchCacheKey(glossaryId: number, query: string, fullSearch: boolean): string { + return `${AddonModGlossaryProvider.ROOT_CACHE_KEY}entriesBySearch:${glossaryId}:${fullSearch}:CONCEPT:ASC:${query}`; } /** @@ -400,8 +356,6 @@ export class AddonModGlossaryProvider { * @param glossaryId Glossary Id. * @param query The search query. * @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. * @returns Resolved with the entries. */ @@ -409,8 +363,6 @@ export class AddonModGlossaryProvider { glossaryId: number, query: string, fullSearch: boolean, - order: string, - sort: string, options: AddonModGlossaryGetEntriesOptions = {}, ): Promise { const site = await CoreSites.getSite(options.siteId); @@ -419,13 +371,13 @@ export class AddonModGlossaryProvider { id: glossaryId, query: query, fullsearch: fullSearch, - order: order, - sort: sort, + order: 'CONCEPT', + sort: 'ASC', from: options.from || 0, limit: options.limit || AddonModGlossaryProvider.LIMIT_ENTRIES, }; const preSets: CoreSiteWSPreSets = { - cacheKey: this.getEntriesBySearchCacheKey(glossaryId, query, fullSearch, order, sort), + cacheKey: this.getEntriesBySearchCacheKey(glossaryId, query, fullSearch), updateFrequency: CoreSite.FREQUENCY_SOMETIMES, component: AddonModGlossaryProvider.COMPONENT, componentId: options.cmId, @@ -441,22 +393,17 @@ export class AddonModGlossaryProvider { * @param glossaryId Glossary Id. * @param query The search query. * @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. - * @returns Resolved when data is invalidated. */ async invalidateEntriesBySearch( glossaryId: number, query: string, fullSearch: boolean, - order: string, - sort: string, siteId?: string, ): Promise { 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); } @@ -468,7 +415,7 @@ export class AddonModGlossaryProvider { * @returns The cache key. */ 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 siteId Site ID. If not defined, current site. - * @returns Promise resolved when categories data has been invalidated, */ async invalidateCategories(glossaryId: number, siteId?: string): Promise { const site = await CoreSites.getSite(siteId); @@ -548,7 +494,7 @@ export class AddonModGlossaryProvider { * @returns Cache key. */ 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 = {}, ): Promise { // 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, readingStrategy: CoreSitesReadingStrategy.ONLY_CACHE, cmId: options.cmId, @@ -661,6 +607,30 @@ export class AddonModGlossaryProvider { 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 { + 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 { + 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. * @@ -695,7 +665,6 @@ export class AddonModGlossaryProvider { * * @param entryId Entry Id. * @param siteId Site ID. If not defined, current site. - * @returns Resolved when data is invalidated. */ async invalidateEntry(entryId: number, siteId?: string): Promise { const site = await CoreSites.getSite(siteId); @@ -708,7 +677,6 @@ export class AddonModGlossaryProvider { * * @param entries Entry objects to invalidate. * @param siteId Site ID. If not defined, current site. - * @returns Resolved when data is invalidated. */ protected async invalidateEntries(entries: AddonModGlossaryEntry[], siteId?: string): Promise { const keys: string[] = []; @@ -727,7 +695,6 @@ export class AddonModGlossaryProvider { * * @param moduleId The module ID. * @param courseId Course ID. - * @returns Promise resolved when data is invalidated. */ async invalidateContent(moduleId: number, courseId: number): Promise { const glossary = await this.getGlossary(courseId, moduleId); @@ -747,7 +714,6 @@ export class AddonModGlossaryProvider { * @param glossary The glossary object. * @param onlyEntriesList If true, entries won't be invalidated. * @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 { siteId = siteId || CoreSites.getCurrentSiteId(); @@ -755,7 +721,7 @@ export class AddonModGlossaryProvider { const promises: Promise[] = []; 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, readingStrategy: CoreSitesReadingStrategy.PREFER_CACHE, siteId, @@ -765,21 +731,17 @@ export class AddonModGlossaryProvider { glossary.browsemodes.forEach((mode) => { switch (mode) { case 'letter': - promises.push(this.invalidateEntriesByLetter(glossary.id, 'ALL', siteId)); + promises.push(this.invalidateEntriesByLetter(glossary.id, siteId)); break; case 'cat': - promises.push(this.invalidateEntriesByCategory( - glossary.id, - AddonModGlossaryProvider.SHOW_ALL_CATEGORIES, - siteId, - )); + promises.push(this.invalidateEntriesByCategory(glossary.id, siteId)); break; case 'date': - promises.push(this.invalidateEntriesByDate(glossary.id, 'CREATION', 'DESC', siteId)); - promises.push(this.invalidateEntriesByDate(glossary.id, 'UPDATE', 'DESC', siteId)); + promises.push(this.invalidateEntriesByDate(glossary.id, 'CREATION', siteId)); + promises.push(this.invalidateEntriesByDate(glossary.id, 'UPDATE', siteId)); break; case 'author': - promises.push(this.invalidateEntriesByAuthor(glossary.id, 'ALL', 'LASTNAME', 'ASC', siteId)); + promises.push(this.invalidateEntriesByAuthor(glossary.id, siteId)); break; default: } @@ -857,13 +819,10 @@ export class AddonModGlossaryProvider { // Convenience function to store a new entry to be synchronized later. const storeOffline = async (): Promise => { - const discardTime = otherOptions.discardEntry?.timecreated; - if (otherOptions.checkDuplicates) { // Check if the entry is duplicated in online or offline mode. const conceptUsed = await this.isConceptUsed(glossaryId, concept, { cmId: otherOptions.cmId, - timeCreated: discardTime, siteId: otherOptions.siteId, }); @@ -877,17 +836,16 @@ export class AddonModGlossaryProvider { throw new CoreError('Error adding entry.'); } - await AddonModGlossaryOffline.addNewEntry( + await AddonModGlossaryOffline.addOfflineEntry( glossaryId, concept, definition, courseId, + otherOptions.timeCreated ?? Date.now(), entryOptions, attachments, - otherOptions.timeCreated, otherOptions.siteId, undefined, - otherOptions.discardEntry, ); return false; @@ -898,19 +856,9 @@ export class AddonModGlossaryProvider { 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 to add it in online. - return await this.addEntryOnline( + const entryId = await this.addEntryOnline( glossaryId, concept, definition, @@ -918,6 +866,8 @@ export class AddonModGlossaryProvider { attachments, otherOptions.siteId, ); + + return entryId; } catch (error) { if (otherOptions.allowOffline && !CoreUtils.isWebServiceError(error)) { // Couldn't connect to server, store in offline. @@ -959,7 +909,7 @@ export class AddonModGlossaryProvider { }; if (attachId) { - params.options!.push({ + params.options?.push({ name: 'attachmentsid', value: String(attachId), }); @@ -967,9 +917,71 @@ export class AddonModGlossaryProvider { const response = await site.write('mod_glossary_add_entry', params); + CoreEvents.trigger(GLOSSARY_ENTRY_ADDED, { glossaryId, entryId: response.entryid }, siteId); + 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, + attachId?: number, + siteId?: string, + ): Promise { + 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('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 { + 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. * @@ -989,7 +1001,7 @@ export class AddonModGlossaryProvider { // If we get here, there's no offline entry with this name, check online. // 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, readingStrategy: CoreSitesReadingStrategy.PREFER_CACHE, siteId: options.siteId, @@ -1010,15 +1022,14 @@ export class AddonModGlossaryProvider { * @param mode The mode in which the glossary was viewed. * @param name Name of the glossary. * @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 { + async logView(glossaryId: number, mode: string, name?: string, siteId?: string): Promise { const params: AddonModGlossaryViewGlossaryWSParams = { id: glossaryId, mode: mode, }; - return CoreCourseLogHelper.logSingle( + await CoreCourseLogHelper.logSingle( 'mod_glossary_view_glossary', params, AddonModGlossaryProvider.COMPONENT, @@ -1037,14 +1048,13 @@ export class AddonModGlossaryProvider { * @param glossaryId Glossary ID. * @param name Name of the glossary. * @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 { + async logEntryView(entryId: number, glossaryId: number, name?: string, siteId?: string): Promise { const params: AddonModGlossaryViewEntryWSParams = { id: entryId, }; - return CoreCourseLogHelper.logSingle( + await CoreCourseLogHelper.logSingle( 'mod_glossary_view_entry', params, AddonModGlossaryProvider.COMPONENT, @@ -1063,7 +1073,6 @@ export class AddonModGlossaryProvider { * @param entries Entries. * @param from The "page" the entries belong to. * @param siteId Site ID. If not defined, current site. - * @returns Promise resolved when done. */ protected async storeEntries( glossaryId: number, @@ -1081,7 +1090,6 @@ export class AddonModGlossaryProvider { * @param entryId Entry ID. * @param from The "page" the entry belongs to. * @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 { 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 */ export interface CoreEventsData { - [AddonModGlossaryProvider.ADD_ENTRY_EVENT]: AddonModGlossaryAddEntryEventData; - [AddonModGlossarySyncProvider.AUTO_SYNCED]: AddonModGlossaryAutoSyncData; + [GLOSSARY_ENTRY_ADDED]: AddonModGlossaryEntryAddedEventData; + [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; 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[]; }; +/** + * 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. */ @@ -1389,37 +1446,12 @@ export type AddonModGlossaryViewEntryWSParams = { */ export type AddonModGlossaryAddEntryOptions = { 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. checkDuplicates?: boolean; // Check for duplicates before storing offline. Only used if allowOffline is true. cmId?: number; // Module ID. 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. */ diff --git a/src/addons/mod/glossary/services/handlers/edit-link.ts b/src/addons/mod/glossary/services/handlers/edit-link.ts index 8859a6d72..541a975ed 100644 --- a/src/addons/mod/glossary/services/handlers/edit-link.ts +++ b/src/addons/mod/glossary/services/handlers/edit-link.ts @@ -51,14 +51,8 @@ export class AddonModGlossaryEditLinkHandlerService extends CoreContentLinksHand ); await CoreNavigator.navigateToSitePath( - AddonModGlossaryModuleHandlerService.PAGE_NAME + '/edit/0', - { - params: { - courseId: module.course, - cmId: module.id, - }, - siteId, - }, + AddonModGlossaryModuleHandlerService.PAGE_NAME + `/${module.course}/${module.id}/entry/new`, + { siteId }, ); } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'addon.mod_glossary.errorloadingglossary', true); diff --git a/src/addons/mod/glossary/services/handlers/entry-link.ts b/src/addons/mod/glossary/services/handlers/entry-link.ts index d58a0bac3..2402b7f53 100644 --- a/src/addons/mod/glossary/services/handlers/entry-link.ts +++ b/src/addons/mod/glossary/services/handlers/entry-link.ts @@ -56,14 +56,8 @@ export class AddonModGlossaryEntryLinkHandlerService extends CoreContentLinksHan ); await CoreNavigator.navigateToSitePath( - AddonModGlossaryModuleHandlerService.PAGE_NAME + `/entry/${entryId}`, - { - params: { - courseId: module.course, - cmId: module.id, - }, - siteId, - }, + AddonModGlossaryModuleHandlerService.PAGE_NAME + `/${module.course}/${module.id}/entry/${entryId}`, + { siteId }, ); } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'addon.mod_glossary.errorloadingentry', true); diff --git a/src/addons/mod/glossary/services/handlers/prefetch.ts b/src/addons/mod/glossary/services/handlers/prefetch.ts index d6b6cf5f4..f9566bd0e 100644 --- a/src/addons/mod/glossary/services/handlers/prefetch.ts +++ b/src/addons/mod/glossary/services/handlers/prefetch.ts @@ -45,7 +45,7 @@ export class AddonModGlossaryPrefetchHandlerService extends CoreCourseActivityPr const glossary = await AddonModGlossary.getGlossary(courseId, module.id); const entries = await AddonModGlossary.fetchAllEntries( - (options) => AddonModGlossary.getEntriesByLetter(glossary.id, 'ALL', options), + (options) => AddonModGlossary.getEntriesByLetter(glossary.id, options), { cmId: module.id, }, @@ -125,43 +125,23 @@ export class AddonModGlossaryPrefetchHandlerService extends CoreCourseActivityPr break; case 'cat': promises.push(AddonModGlossary.fetchAllEntries( - (newOptions) => AddonModGlossary.getEntriesByCategory( - glossary.id, - AddonModGlossaryProvider.SHOW_ALL_CATEGORIES, - newOptions, - ), + (newOptions) => AddonModGlossary.getEntriesByCategory(glossary.id, newOptions), options, )); break; case 'date': promises.push(AddonModGlossary.fetchAllEntries( - (newOptions) => AddonModGlossary.getEntriesByDate( - glossary.id, - 'CREATION', - 'DESC', - newOptions, - ), + (newOptions) => AddonModGlossary.getEntriesByDate(glossary.id, 'CREATION', newOptions), options, )); promises.push(AddonModGlossary.fetchAllEntries( - (newOptions) => AddonModGlossary.getEntriesByDate( - glossary.id, - 'UPDATE', - 'DESC', - newOptions, - ), + (newOptions) => AddonModGlossary.getEntriesByDate(glossary.id, 'UPDATE', newOptions), options, )); break; case 'author': promises.push(AddonModGlossary.fetchAllEntries( - (newOptions) => AddonModGlossary.getEntriesByAuthor( - glossary.id, - 'ALL', - 'LASTNAME', - 'ASC', - newOptions, - ), + (newOptions) => AddonModGlossary.getEntriesByAuthor(glossary.id, newOptions), options, )); break; @@ -171,7 +151,7 @@ export class AddonModGlossaryPrefetchHandlerService extends CoreCourseActivityPr // Fetch all entries to get information from. promises.push(AddonModGlossary.fetchAllEntries( - (newOptions) => AddonModGlossary.getEntriesByLetter(glossary.id, 'ALL', newOptions), + (newOptions) => AddonModGlossary.getEntriesByLetter(glossary.id, newOptions), options, ).then((entries) => { const promises: Promise[] = []; diff --git a/src/addons/mod/glossary/tests/behat/basic_usage.feature b/src/addons/mod/glossary/tests/behat/basic_usage.feature index 6a32f476c..4d2e0d270 100644 --- a/src/addons/mod/glossary/tests/behat/basic_usage.feature +++ b/src/addons/mod/glossary/tests/behat/basic_usage.feature @@ -154,6 +154,152 @@ Feature: Test basic usage of glossary in app Then I should find "Garlic" 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 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 @@ -192,8 +338,8 @@ Feature: Test basic usage of glossary in app And I should find "Broccoli" in the app And I should find "Cabbage" in the app And I should find "Garlic" in the app - But I should not see "Entries to be synced" - And I should not see "This Glossary has offline data to be synchronised." + But I should not find "Entries to be synced" in the app + And I should not find "This Glossary has offline data to be synchronised." in the app When I press "Garlic" in the app Then I should find "Garlic" in the app diff --git a/src/addons/mod/glossary/tests/behat/fixtures/stub1.txt b/src/addons/mod/glossary/tests/behat/fixtures/stub1.txt new file mode 100644 index 000000000..38257d448 --- /dev/null +++ b/src/addons/mod/glossary/tests/behat/fixtures/stub1.txt @@ -0,0 +1 @@ +This is a stub. diff --git a/src/addons/mod/glossary/tests/behat/fixtures/stub2.txt b/src/addons/mod/glossary/tests/behat/fixtures/stub2.txt new file mode 100644 index 000000000..38257d448 --- /dev/null +++ b/src/addons/mod/glossary/tests/behat/fixtures/stub2.txt @@ -0,0 +1 @@ +This is a stub. diff --git a/src/addons/mod/glossary/tests/behat/fixtures/stub3.txt b/src/addons/mod/glossary/tests/behat/fixtures/stub3.txt new file mode 100644 index 000000000..38257d448 --- /dev/null +++ b/src/addons/mod/glossary/tests/behat/fixtures/stub3.txt @@ -0,0 +1 @@ +This is a stub. diff --git a/src/addons/mod/glossary/tests/behat/navigation.feature b/src/addons/mod/glossary/tests/behat/navigation.feature index 659d286ff..4700884e6 100644 --- a/src/addons/mod/glossary/tests/behat/navigation.feature +++ b/src/addons/mod/glossary/tests/behat/navigation.feature @@ -200,6 +200,17 @@ Feature: Test glossary navigation When I swipe to the left 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 Scenario: Tablet navigation on glossary Given I entered the course "Course 1" as "student1" in the app @@ -280,6 +291,7 @@ Feature: Test glossary navigation | Concept | Tomato | | Definition | Tomato is a fruit | And I press "Save" in the app + And I press "Add a new entry" in the app And I set the following fields to these values in the app: | Concept | Cashew | | Definition | Cashew is a fruit | @@ -300,3 +312,12 @@ Feature: Test glossary navigation When I press "Acerola" 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 + + # 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 diff --git a/src/core/services/utils/utils.ts b/src/core/services/utils/utils.ts index 395afc37e..39ef612e6 100644 --- a/src/core/services/utils/utils.ts +++ b/src/core/services/utils/utils.ts @@ -1772,9 +1772,9 @@ export class CoreUtilsProvider { * @param fallback Value to return if the promise is rejected. * @returns Promise with ignored errors, resolving to the fallback result if provided. */ - async ignoreErrors(promise: Promise): Promise; + async ignoreErrors(promise?: Promise): Promise; async ignoreErrors(promise: Promise, fallback: Fallback): Promise; - async ignoreErrors(promise: Promise, fallback?: Fallback): Promise { + async ignoreErrors(promise?: Promise, fallback?: Fallback): Promise { try { const result = await promise; diff --git a/src/testing/services/behat-dom.ts b/src/testing/services/behat-dom.ts index e2cadf34e..2705a33ac 100644 --- a/src/testing/services/behat-dom.ts +++ b/src/testing/services/behat-dom.ts @@ -421,7 +421,7 @@ export class TestingBehatDomUtilsService { */ findElementBasedOnText( locator: TestingBehatElementLocator, - options: TestingBehatFindOptions, + options: TestingBehatFindOptions = {}, ): HTMLElement | undefined { return this.findElementsBasedOnText(locator, options)[0]; } @@ -437,7 +437,7 @@ export class TestingBehatDomUtilsService { locator: TestingBehatElementLocator, options: TestingBehatFindOptions, ): HTMLElement[] { - const topContainers = this.getCurrentTopContainerElements(options.containerName); + const topContainers = this.getCurrentTopContainerElements(options.containerName ?? ''); let elements: HTMLElement[] = []; for (let i = 0; i < topContainers.length; i++) { diff --git a/src/testing/services/behat-runtime.ts b/src/testing/services/behat-runtime.ts index e9cb4f2e4..b5d73ce60 100644 --- a/src/testing/services/behat-runtime.ts +++ b/src/testing/services/behat-runtime.ts @@ -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 { + 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. * @@ -635,8 +669,8 @@ export type BehatTestsWindow = Window & { }; export type TestingBehatFindOptions = { - containerName: string; - onlyClickable: boolean; + containerName?: string; + onlyClickable?: boolean; }; export type TestingBehatElementLocator = { diff --git a/upgrade.txt b/upgrade.txt index eebe0c6c4..bf8043196 100644 --- a/upgrade.txt +++ b/upgrade.txt @@ -6,6 +6,7 @@ information provided here is intended especially for developers. - 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. - Font Awesome icon library has been updated to 6.3.0. +- Some methods in glossary addon services have changed. === 4.1.0 ===