From d2d8a814f62b67ba98a2f84b50fc49403eb3b59e Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Thu, 30 Mar 2023 12:39:00 +0200 Subject: [PATCH] MOBILE-2652 glossary: Edit attachments --- gulpfile.js | 11 ++- .../tests/behat/behat_app.php | 64 +++++++++++++---- scripts/build-behat-plugin.js | 33 +++++++-- src/addons/mod/glossary/pages/edit/edit.ts | 17 ++++- .../mod/glossary/pages/entry/entry.html | 4 ++ src/addons/mod/glossary/pages/entry/entry.ts | 9 +++ src/addons/mod/glossary/services/glossary.ts | 9 +++ .../glossary/tests/behat/basic_usage.feature | 70 ++++++++++++++----- .../glossary/tests/behat/fixtures/stub1.txt | 1 + .../glossary/tests/behat/fixtures/stub2.txt | 1 + .../glossary/tests/behat/fixtures/stub3.txt | 1 + src/testing/services/behat-dom.ts | 4 +- src/testing/services/behat-runtime.ts | 38 +++++++++- 13 files changed, 220 insertions(+), 42 deletions(-) create mode 100644 src/addons/mod/glossary/tests/behat/fixtures/stub1.txt create mode 100644 src/addons/mod/glossary/tests/behat/fixtures/stub2.txt create mode 100644 src/addons/mod/glossary/tests/behat/fixtures/stub3.txt 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/src/addons/mod/glossary/pages/edit/edit.ts b/src/addons/mod/glossary/pages/edit/edit.ts index d550fd417..ec8ef84d8 100644 --- a/src/addons/mod/glossary/pages/edit/edit.ts +++ b/src/addons/mod/glossary/pages/edit/edit.ts @@ -419,6 +419,7 @@ class AddonModGlossaryOfflineFormHandler extends AddonModGlossaryFormHandler { * @inheritdoc */ async save(glossary: AddonModGlossaryGlossary): Promise { + const originalData = this.page.data; const data = this.page.data; // Upload attachments first if any. @@ -428,6 +429,10 @@ class AddonModGlossaryOfflineFormHandler extends AddonModGlossaryFormHandler { 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); @@ -653,8 +658,18 @@ class AddonModGlossaryOnlineFormHandler extends AddonModGlossaryFormHandler { 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); + 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' }); diff --git a/src/addons/mod/glossary/pages/entry/entry.html b/src/addons/mod/glossary/pages/entry/entry.html index b0de28204..aa50fb45b 100644 --- a/src/addons/mod/glossary/pages/entry/entry.html +++ b/src/addons/mod/glossary/pages/entry/entry.html @@ -73,6 +73,10 @@ [componentId]="componentId"> +
+ + +
{{ 'core.tag.tags' | translate }}:
diff --git a/src/addons/mod/glossary/pages/entry/entry.ts b/src/addons/mod/glossary/pages/entry/entry.ts index c1649d67d..ccbcedea7 100644 --- a/src/addons/mod/glossary/pages/entry/entry.ts +++ b/src/addons/mod/glossary/pages/entry/entry.ts @@ -23,6 +23,7 @@ import { CoreCommentsCommentsComponent } from '@features/comments/components/com 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 { CoreNetwork } from '@services/network'; @@ -54,6 +55,7 @@ export class AddonModGlossaryEntryPage implements OnInit, OnDestroy { componentId?: number; onlineEntry?: AddonModGlossaryEntry; offlineEntry?: AddonModGlossaryOfflineEntry; + offlineEntryFiles?: FileEntry[]; entries!: AddonModGlossaryEntryEntriesSwipeManager; glossary?: AddonModGlossaryGlossary; entryUpdatedObserver?: CoreEventObserver; @@ -263,6 +265,13 @@ export class AddonModGlossaryEntryPage implements OnInit, OnDestroy { 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) { diff --git a/src/addons/mod/glossary/services/glossary.ts b/src/addons/mod/glossary/services/glossary.ts index 012a4c1d0..487544639 100644 --- a/src/addons/mod/glossary/services/glossary.ts +++ b/src/addons/mod/glossary/services/glossary.ts @@ -930,6 +930,7 @@ export class AddonModGlossaryProvider { * @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( @@ -938,6 +939,7 @@ export class AddonModGlossaryProvider { concept: string, definition: string, options?: Record, + attachId?: number, siteId?: string, ): Promise { const site = await CoreSites.getSite(siteId); @@ -950,6 +952,13 @@ export class AddonModGlossaryProvider { 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) { diff --git a/src/addons/mod/glossary/tests/behat/basic_usage.feature b/src/addons/mod/glossary/tests/behat/basic_usage.feature index 1a271896e..f4a4f9862 100644 --- a/src/addons/mod/glossary/tests/behat/basic_usage.feature +++ b/src/addons/mod/glossary/tests/behat/basic_usage.feature @@ -154,42 +154,51 @@ 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 (basic info) + Scenario: Edit entries Given I entered the glossary activity "Test glossary" on course "Course 1" as "student1" in the app # Online - When 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 | - And I press "Save" in the app - Then I should find "Cashew" in the app - - When I press "Cashew" in the app + When I press "Cucumber" in the app And I press "Edit entry" in the app - Then the field "Concept" matches value "Cashew" in the app - And the field "Definition" matches value "Cashew is a fruit" in the app + Then the field "Concept" matches value "Cucumber" in the app + And the field "Definition" matches value "Sweet cucumber" 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 - But I should not find "Cashew 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 "This entry should be automatically linked" "ion-toggle" should be selected 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 "Save" in the app - And I press the back button 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 "Cashew" in the app + But I should not find "Cucumber" in the app # Offline When I press "Add a new entry" in the app @@ -197,6 +206,10 @@ Feature: Test basic usage of glossary in app And I set the following fields to these values in the app: | Concept | Broccoli | | Definition | Brassica oleracea var. italica | + 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 @@ -206,10 +219,14 @@ Feature: Test basic usage of glossary in 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 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 @@ -217,15 +234,34 @@ Feature: Test basic usage of glossary in 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 - But I should not find "Brassica oleracea var. italica" 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 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/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 = {