MOBILE-2652 glossary: Edit attachments

main
Noel De Martin 2023-03-30 12:39:00 +02:00
parent 9391fe4122
commit d2d8a814f6
13 changed files with 220 additions and 42 deletions

View File

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

View File

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

View File

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

View File

@ -419,6 +419,7 @@ class AddonModGlossaryOfflineFormHandler extends AddonModGlossaryFormHandler {
* @inheritdoc * @inheritdoc
*/ */
async save(glossary: AddonModGlossaryGlossary): Promise<boolean> { async save(glossary: AddonModGlossaryGlossary): Promise<boolean> {
const originalData = this.page.data;
const data = this.page.data; const data = this.page.data;
// Upload attachments first if any. // Upload attachments first if any.
@ -428,6 +429,10 @@ class AddonModGlossaryOfflineFormHandler extends AddonModGlossaryFormHandler {
offlineAttachments = await this.storeAttachments(glossary, data.timecreated); offlineAttachments = await this.storeAttachments(glossary, data.timecreated);
} }
if (originalData.concept !== data.concept) {
await AddonModGlossaryHelper.deleteStoredFiles(glossary.id, originalData.concept, data.timecreated);
}
// Save entry data. // Save entry data.
await this.updateOfflineEntry(glossary, offlineAttachments); await this.updateOfflineEntry(glossary, offlineAttachments);
@ -653,8 +658,18 @@ class AddonModGlossaryOnlineFormHandler extends AddonModGlossaryFormHandler {
const options = this.getSaveOptions(glossary); const options = this.getSaveOptions(glossary);
const definition = CoreTextUtils.formatHtmlLines(data.definition); 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. // 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' }); CoreEvents.trigger(CoreEvents.ACTIVITY_DATA_SENT, { module: 'glossary' });

View File

@ -73,6 +73,10 @@
[componentId]="componentId"> [componentId]="componentId">
</core-file> </core-file>
</div> </div>
<div *ngIf="offlineEntry && offlineEntryFiles">
<core-local-file *ngFor="let file of offlineEntryFiles" [file]="file">
</core-local-file>
</div>
<ion-item class="ion-text-wrap" *ngIf="onlineEntry && tagsEnabled && entry && onlineEntry.tags && onlineEntry.tags.length > 0"> <ion-item class="ion-text-wrap" *ngIf="onlineEntry && tagsEnabled && entry && onlineEntry.tags && onlineEntry.tags.length > 0">
<ion-label> <ion-label>
<div slot="start">{{ 'core.tag.tags' | translate }}:</div> <div slot="start">{{ 'core.tag.tags' | translate }}:</div>

View File

@ -23,6 +23,7 @@ import { CoreCommentsCommentsComponent } from '@features/comments/components/com
import { CoreComments } from '@features/comments/services/comments'; import { CoreComments } from '@features/comments/services/comments';
import { CoreRatingInfo } from '@features/rating/services/rating'; import { CoreRatingInfo } from '@features/rating/services/rating';
import { CoreTag } from '@features/tag/services/tag'; import { CoreTag } from '@features/tag/services/tag';
import { FileEntry } from '@ionic-native/file/ngx';
import { IonRefresher } from '@ionic/angular'; import { IonRefresher } from '@ionic/angular';
import { CoreNavigator } from '@services/navigator'; import { CoreNavigator } from '@services/navigator';
import { CoreNetwork } from '@services/network'; import { CoreNetwork } from '@services/network';
@ -54,6 +55,7 @@ export class AddonModGlossaryEntryPage implements OnInit, OnDestroy {
componentId?: number; componentId?: number;
onlineEntry?: AddonModGlossaryEntry; onlineEntry?: AddonModGlossaryEntry;
offlineEntry?: AddonModGlossaryOfflineEntry; offlineEntry?: AddonModGlossaryOfflineEntry;
offlineEntryFiles?: FileEntry[];
entries!: AddonModGlossaryEntryEntriesSwipeManager; entries!: AddonModGlossaryEntryEntriesSwipeManager;
glossary?: AddonModGlossaryGlossary; glossary?: AddonModGlossaryGlossary;
entryUpdatedObserver?: CoreEventObserver; entryUpdatedObserver?: CoreEventObserver;
@ -263,6 +265,13 @@ export class AddonModGlossaryEntryPage implements OnInit, OnDestroy {
const glossary = await this.loadGlossary(); const glossary = await this.loadGlossary();
this.offlineEntry = await AddonModGlossaryOffline.getOfflineEntry(glossary.id, timecreated); 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.canEdit = true;
this.canDelete = true; this.canDelete = true;
} catch (error) { } catch (error) {

View File

@ -930,6 +930,7 @@ export class AddonModGlossaryProvider {
* @param concept Glossary entry concept. * @param concept Glossary entry concept.
* @param definition Glossary entry concept definition. * @param definition Glossary entry concept definition.
* @param options Options for the entry. * @param options Options for the entry.
* @param attachId Attachments ID (if any attachment).
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
*/ */
async updateEntry( async updateEntry(
@ -938,6 +939,7 @@ export class AddonModGlossaryProvider {
concept: string, concept: string,
definition: string, definition: string,
options?: Record<string, AddonModGlossaryEntryOption>, options?: Record<string, AddonModGlossaryEntryOption>,
attachId?: number,
siteId?: string, siteId?: string,
): Promise<void> { ): Promise<void> {
const site = await CoreSites.getSite(siteId); const site = await CoreSites.getSite(siteId);
@ -950,6 +952,13 @@ export class AddonModGlossaryProvider {
options: CoreUtils.objectToArrayOfObjects(options || {}, 'name', 'value'), options: CoreUtils.objectToArrayOfObjects(options || {}, 'name', 'value'),
}; };
if (attachId) {
params.options?.push({
name: 'attachmentsid',
value: String(attachId),
});
}
const response = await site.write<AddonModGlossaryUpdateEntryWSResponse>('mod_glossary_update_entry', params); const response = await site.write<AddonModGlossaryUpdateEntryWSResponse>('mod_glossary_update_entry', params);
if (!response.result) { if (!response.result) {

View File

@ -154,42 +154,51 @@ Feature: Test basic usage of glossary in app
Then I should find "Garlic" in the app Then I should find "Garlic" in the app
And I should find "Allium sativum" in the app And I should find "Allium sativum" in the app
Scenario: Edit entries (basic info) Scenario: Edit entries
Given I entered the glossary activity "Test glossary" on course "Course 1" as "student1" in the app Given I entered the glossary activity "Test glossary" on course "Course 1" as "student1" in the app
# Online # Online
When I press "Add a new entry" in the app When I press "Cucumber" 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
And I press "Edit entry" in the app And I press "Edit entry" in the app
Then the field "Concept" matches value "Cashew" in the app Then the field "Concept" matches value "Cucumber" in the app
And the field "Definition" matches value "Cashew is a fruit" 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: When I set the following fields to these values in the app:
| Concept | Coconut | | Concept | Coconut |
| Definition | Coconut is a fruit | | 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 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 "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 "Match whole words only" "ion-toggle" in the app
And I press "Save" in the app And I press "Save" in the app
Then I should find "Coconut is a fruit" 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 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 "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 And "Match whole words only" "ion-toggle" should be selected in the app
When I press "Save" in the app When I press "Delete" within "stub2.txt" "ion-item" in the app
And I press the back button 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 Then I should find "Coconut" in the app
And I should find "Potato" 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 # Offline
When I press "Add a new entry" in the app 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: And I set the following fields to these values in the app:
| Concept | Broccoli | | Concept | Broccoli |
| Definition | Brassica oleracea var. italica | | Definition | Brassica oleracea var. italica |
And I press "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 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 "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 "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 When I press "Broccoli" in the app
Then I should find "Brassica oleracea var. italica" 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 When I press "Edit entry" in the app
Then the field "Concept" matches value "Broccoli" in the app Then the field "Concept" matches value "Broccoli" in the app
And the field "Definition" matches value "Brassica oleracea var. italica" in the app And the field "Definition" matches value "Brassica oleracea var. italica" in the app
And 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 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 "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 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: When I set the following fields to these values in the app:
| Concept | Pickle | | Concept | Pickle |
| Definition | Pickle Rick | | 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 And I press "Save" in the app
Then I should find "Pickle Rick" 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 When I press the back button in the app
Then I should find "Pickle" in the app Then I should find "Pickle" in the app
And I should find "Potato" in the app And I should find "Potato" in the app
But I should not find "Broccoli" in the app But I should not find "Broccoli" in the app
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 Scenario: Delete entries
Given I entered the glossary activity "Test glossary" on course "Course 1" as "student1" in the app Given I entered the glossary activity "Test glossary" on course "Course 1" as "student1" in the app

View File

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

View File

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

View File

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

View File

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

View File

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