Merge pull request #3311 from crazyserver/MOBILE-4061

Mobile 4061
main
Dani Palou 2022-06-14 16:07:17 +02:00 committed by GitHub
commit 7090f8d918
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
59 changed files with 2035 additions and 716 deletions

View File

@ -94,7 +94,7 @@ class behat_app extends behat_app_helper {
public function i_wait_the_app_to_restart() { public function i_wait_the_app_to_restart() {
// Wait window to reload. // Wait window to reload.
$this->spin(function() { $this->spin(function() {
$result = $this->evaluate_script("return !window.behat;"); $result = $this->js("return !window.behat;");
if (!$result) { if (!$result) {
throw new DriverException('Window is not reloading properly.'); throw new DriverException('Window is not reloading properly.');
@ -121,7 +121,7 @@ class behat_app extends behat_app_helper {
$containerName = json_encode($containerName); $containerName = json_encode($containerName);
$this->spin(function() use ($not, $locator, $containerName) { $this->spin(function() use ($not, $locator, $containerName) {
$result = $this->evaluate_script("return window.behat.find($locator, $containerName);"); $result = $this->js("return window.behat.find($locator, $containerName);");
if ($not && $result === 'OK') { if ($not && $result === 'OK') {
throw new DriverException('Error, found an item that should not be found'); throw new DriverException('Error, found an item that should not be found');
@ -147,7 +147,7 @@ class behat_app extends behat_app_helper {
$locator = $this->parse_element_locator($locator); $locator = $this->parse_element_locator($locator);
$this->spin(function() use ($locator) { $this->spin(function() use ($locator) {
$result = $this->evaluate_script("return window.behat.scrollTo($locator);"); $result = $this->js("return window.behat.scrollTo($locator);");
if ($result !== 'OK') { if ($result !== 'OK') {
throw new DriverException('Error finding item - ' . $result); throw new DriverException('Error finding item - ' . $result);
@ -170,7 +170,7 @@ class behat_app extends behat_app_helper {
*/ */
public function i_load_more_items_in_the_app(bool $not = false) { public function i_load_more_items_in_the_app(bool $not = false) {
$this->spin(function() use ($not) { $this->spin(function() use ($not) {
$result = $this->evaluate_async_script('return window.behat.loadMoreItems();'); $result = $this->js('return await window.behat.loadMoreItems();');
if ($not && $result !== 'ERROR: All items are already loaded.') { if ($not && $result !== 'ERROR: All items are already loaded.') {
throw new DriverException('It should not have been possible to load more items'); throw new DriverException('It should not have been possible to load more items');
@ -195,7 +195,7 @@ class behat_app extends behat_app_helper {
public function i_swipe_in_the_app(string $direction) { public function i_swipe_in_the_app(string $direction) {
$method = 'swipe' . ucwords($direction); $method = 'swipe' . ucwords($direction);
$this->evaluate_script("window.behat.getAngularInstance('ion-content', 'CoreSwipeNavigationDirective').$method()"); $this->js("window.behat.getAngularInstance('ion-content', 'CoreSwipeNavigationDirective').$method()");
$this->wait_for_pending_js(); $this->wait_for_pending_js();
@ -214,7 +214,7 @@ class behat_app extends behat_app_helper {
$locator = $this->parse_element_locator($locator); $locator = $this->parse_element_locator($locator);
$this->spin(function() use ($locator, $not) { $this->spin(function() use ($locator, $not) {
$result = $this->evaluate_script("return window.behat.isSelected($locator);"); $result = $this->js("return window.behat.isSelected($locator);");
switch ($result) { switch ($result) {
case 'YES': case 'YES':
@ -318,7 +318,7 @@ class behat_app extends behat_app_helper {
$this->login($username); $this->login($username);
} }
$mycoursesfound = $this->evaluate_script("return window.behat.find({ text: 'My courses', selector: 'ion-tab-button'});"); $mycoursesfound = $this->js("return window.behat.find({ text: 'My courses', selector: 'ion-tab-button'});");
if ($mycoursesfound !== 'OK') { if ($mycoursesfound !== 'OK') {
// My courses not present enter from Dashboard. // My courses not present enter from Dashboard.
@ -370,7 +370,7 @@ class behat_app extends behat_app_helper {
*/ */
public function i_press_the_standard_button_in_the_app(string $button) { public function i_press_the_standard_button_in_the_app(string $button) {
$this->spin(function() use ($button) { $this->spin(function() use ($button) {
$result = $this->evaluate_script("return window.behat.pressStandard('$button');"); $result = $this->js("return await window.behat.pressStandard('$button');");
if ($result !== 'OK') { if ($result !== 'OK') {
throw new DriverException('Error pressing standard button - ' . $result); throw new DriverException('Error pressing standard button - ' . $result);
@ -408,7 +408,7 @@ class behat_app extends behat_app_helper {
], ],
]); ]);
$this->evaluate_script("return window.pushNotifications.notificationClicked($notification)"); $this->js("window.behat.notificationClicked($notification)");
$this->wait_for_pending_js(); $this->wait_for_pending_js();
} }
@ -494,7 +494,7 @@ class behat_app extends behat_app_helper {
*/ */
public function i_close_the_popup_in_the_app() { public function i_close_the_popup_in_the_app() {
$this->spin(function() { $this->spin(function() {
$result = $this->evaluate_script("return window.behat.closePopup();"); $result = $this->js("return window.behat.closePopup();");
if ($result !== 'OK') { if ($result !== 'OK') {
throw new DriverException('Error closing popup - ' . $result); throw new DriverException('Error closing popup - ' . $result);
@ -532,7 +532,7 @@ class behat_app extends behat_app_helper {
$locator = $this->parse_element_locator($locator); $locator = $this->parse_element_locator($locator);
$this->spin(function() use ($locator) { $this->spin(function() use ($locator) {
$result = $this->evaluate_script("return window.behat.press($locator);"); $result = $this->js("return await window.behat.press($locator);");
if ($result !== 'OK') { if ($result !== 'OK') {
throw new DriverException('Error pressing item - ' . $result); throw new DriverException('Error pressing item - ' . $result);
@ -562,14 +562,14 @@ class behat_app extends behat_app_helper {
$this->spin(function() use ($selectedtext, $selected, $locator) { $this->spin(function() use ($selectedtext, $selected, $locator) {
// Don't do anything if the item is already in the expected state. // Don't do anything if the item is already in the expected state.
$result = $this->evaluate_script("return window.behat.isSelected($locator);"); $result = $this->js("return window.behat.isSelected($locator);");
if ($result === $selected) { if ($result === $selected) {
return true; return true;
} }
// Press item. // Press item.
$result = $this->evaluate_script("return window.behat.press($locator);"); $result = $this->js("return await window.behat.press($locator);");
if ($result !== 'OK') { if ($result !== 'OK') {
throw new DriverException('Error pressing item - ' . $result); throw new DriverException('Error pressing item - ' . $result);
@ -578,7 +578,7 @@ class behat_app extends behat_app_helper {
// Check that it worked as expected. // Check that it worked as expected.
$this->wait_for_pending_js(); $this->wait_for_pending_js();
$result = $this->evaluate_script("return window.behat.isSelected($locator);"); $result = $this->js("return window.behat.isSelected($locator);");
switch ($result) { switch ($result) {
case 'YES': case 'YES':
@ -612,7 +612,7 @@ class behat_app extends behat_app_helper {
$value = addslashes_js($value); $value = addslashes_js($value);
$this->spin(function() use ($field, $value) { $this->spin(function() use ($field, $value) {
$result = $this->evaluate_script("return window.behat.setField(\"$field\", \"$value\");"); $result = $this->js("return await window.behat.setField(\"$field\", \"$value\");");
if ($result !== 'OK') { if ($result !== 'OK') {
throw new DriverException('Error setting field - ' . $result); throw new DriverException('Error setting field - ' . $result);
@ -624,6 +624,21 @@ class behat_app extends behat_app_helper {
$this->wait_for_pending_js(); $this->wait_for_pending_js();
} }
/**
* Fills a form with field/value data.
*
* @Given /^I set the following fields to these values in the app:$/
* @param TableNode $data
*/
public function i_set_the_following_fields_to_these_values_in_the_app(TableNode $data) {
$datahash = $data->getRowsHash();
// The action depends on the field type.
foreach ($datahash as $locator => $value) {
$this->i_set_the_field_in_the_app($locator, $value);
}
}
/** /**
* Checks that the current header stripe in the app contains the expected text. * Checks that the current header stripe in the app contains the expected text.
* *
@ -636,7 +651,7 @@ class behat_app extends behat_app_helper {
*/ */
public function the_header_should_be_in_the_app(string $text) { public function the_header_should_be_in_the_app(string $text) {
$this->spin(function() use ($text) { $this->spin(function() use ($text) {
$result = $this->evaluate_script('return window.behat.getHeader();'); $result = $this->js('return window.behat.getHeader();');
if (substr($result, 0, 3) !== 'OK:') { if (substr($result, 0, 3) !== 'OK:') {
throw new DriverException('Error getting header - ' . $result); throw new DriverException('Error getting header - ' . $result);
@ -717,25 +732,8 @@ class behat_app extends behat_app_helper {
* @When I run cron tasks in the app * @When I run cron tasks in the app
*/ */
public function i_run_cron_tasks_in_the_app() { public function i_run_cron_tasks_in_the_app() {
$session = $this->getSession(); $this->js('await window.behat.forceSyncExecution()');
$this->wait_for_pending_js();
// Force cron tasks execution and wait until they are completed.
$operationid = random_string();
$session->executeScript(
"cronProvider.forceSyncExecution().then(() => { window['behat_{$operationid}_completed'] = true; });"
);
$this->spin(
function() use ($session, $operationid) {
return $session->evaluateScript("window['behat_{$operationid}_completed'] || false");
},
false,
60,
new ExpectationException('Forced cron tasks in the app took too long to complete', $session)
);
// Trigger Angular change detection.
$this->trigger_angular_change_detection();
} }
/** /**
@ -744,28 +742,8 @@ class behat_app extends behat_app_helper {
* @When I wait loading to finish in the app * @When I wait loading to finish in the app
*/ */
public function i_wait_loading_to_finish_in_the_app() { public function i_wait_loading_to_finish_in_the_app() {
$session = $this->getSession(); $this->js('await window.behat.waitLoadingToFinish()');
$this->wait_for_pending_js();
$this->spin(
function() use ($session) {
$this->trigger_angular_change_detection();
$nodes = $this->find_all('css', 'core-loading ion-spinner');
foreach ($nodes as $node) {
if (!$node->isVisible()) {
continue;
}
return false;
}
return true;
},
false,
60,
new ExpectationException('"Loading took too long to complete', $session)
);
} }
/** /**
@ -786,7 +764,7 @@ class behat_app extends behat_app_helper {
$this->getSession()->switchToWindow($names[1]); $this->getSession()->switchToWindow($names[1]);
} }
$this->execute_script('window.close();'); $this->js('window.close();');
$this->getSession()->switchToWindow($names[0]); $this->getSession()->switchToWindow($names[0]);
} }
@ -798,7 +776,7 @@ class behat_app extends behat_app_helper {
* @throws DriverException If the navigator.online mode is not available * @throws DriverException If the navigator.online mode is not available
*/ */
public function i_switch_offline_mode(string $offline) { public function i_switch_offline_mode(string $offline) {
$this->execute_script("appProvider.setForceOffline($offline);"); $this->js("window.behat.network.setForceOffline($offline);");
} }
} }

View File

@ -318,7 +318,7 @@ class behat_app_helper extends behat_base {
$initOptions->skipOnBoarding = $options['skiponboarding'] ?? true; $initOptions->skipOnBoarding = $options['skiponboarding'] ?? true;
$initOptions->configOverrides = $this->appconfig; $initOptions->configOverrides = $this->appconfig;
$this->execute_script('window.behatInit(' . json_encode($initOptions) . ');'); $this->js('window.behatInit(' . json_encode($initOptions) . ');');
} catch (Exception $error) { } catch (Exception $error) {
throw new DriverException('Moodle App not running or not running on Automated mode.'); throw new DriverException('Moodle App not running or not running on Automated mode.');
} }
@ -433,28 +433,28 @@ class behat_app_helper extends behat_base {
} }
} }
/** /**
* Trigger Angular change detection. * Evaluate and execute scripts checking for promises if needed.
*/
protected function trigger_angular_change_detection() {
$this->getSession()->executeScript('ngZone.run(() => {});');
}
/**
* Evaluate a script that returns a Promise.
* *
* @param string $script * @param string $script
* @return mixed Resolved promise result. * @return mixed Resolved promise result.
*/ */
protected function evaluate_async_script(string $script) { protected function js(string $script) {
$script = preg_replace('/^return\s+/', '', $script); $scriptnoreturn = preg_replace('/^return\s+/', '', $script);
$script = preg_replace('/;$/', '', $script); $scriptnoreturn = preg_replace('/;$/', '', $scriptnoreturn);
if (!preg_match('/^await\s+/', $scriptnoreturn)) {
// No async.
return $this->evaluate_script($script);
}
$script = preg_replace('/^await\s+/', '', $scriptnoreturn);
$start = microtime(true); $start = microtime(true);
$promisevariable = 'PROMISE_RESULT_' . time(); $promisevariable = 'PROMISE_RESULT_' . time();
$timeout = self::get_timeout(); $timeout = self::get_extended_timeout();
$this->evaluate_script("Promise.resolve($script) $res = $this->evaluate_script("Promise.resolve($script)
.then(result => window.$promisevariable = result) .then(result => window.$promisevariable = result)
.catch(error => window.$promisevariable = 'Async code rejected: ' + error?.message);"); .catch(error => window.$promisevariable = 'Async code rejected: ' + error?.message);");
@ -463,6 +463,7 @@ class behat_app_helper extends behat_base {
throw new DriverException("Async script not resolved after $timeout seconds"); throw new DriverException("Async script not resolved after $timeout seconds");
} }
// 0.1 seconds.
usleep(100000); usleep(100000);
} while (!$this->evaluate_script("return '$promisevariable' in window;")); } while (!$this->evaluate_script("return '$promisevariable' in window;"));
@ -522,7 +523,7 @@ class behat_app_helper extends behat_base {
$successXPath = '//page-core-mainmenu'; $successXPath = '//page-core-mainmenu';
} }
$this->handle_url_and_wait_page_to_load($url, $successXPath); $this->handle_url($url, $successXPath);
} }
/** /**
@ -537,7 +538,7 @@ class behat_app_helper extends behat_base {
$urlscheme = $this->get_mobile_url_scheme(); $urlscheme = $this->get_mobile_url_scheme();
$url = "$urlscheme://link=" . urlencode($CFG->behat_wwwroot.$path); $url = "$urlscheme://link=" . urlencode($CFG->behat_wwwroot.$path);
$this->handle_url_and_wait_page_to_load($url); $this->handle_url($url);
} }
/** /**
@ -546,11 +547,13 @@ class behat_app_helper extends behat_base {
* @param string $customurl To navigate. * @param string $customurl To navigate.
* @param string $successXPath The XPath of the element to lookat after navigation. * @param string $successXPath The XPath of the element to lookat after navigation.
*/ */
protected function handle_url_and_wait_page_to_load(string $customurl, string $successXPath = '') { protected function handle_url(string $customurl, string $successXPath = '') {
// Instead of using evaluate_async_script, we wait for the path to load. // Instead of using evaluate_async_script, we wait for the path to load.
$this->evaluate_script("return window.behat.handleCustomURL('$customurl')"); $result = $this->js("return await window.behat.handleCustomURL('$customurl');");
$this->wait_for_pending_js(); if ($result !== 'OK') {
throw new DriverException('Error handling url - ' . $result);
}
if (!empty($successXPath)) { if (!empty($successXPath)) {
// Wait until the page appears. // Wait until the page appears.
@ -562,10 +565,9 @@ class behat_app_helper extends behat_base {
} }
throw new DriverException('Moodle App custom URL page not loaded'); throw new DriverException('Moodle App custom URL page not loaded');
}, false, 30); }, false, 30);
// Wait for JS to finish as well.
$this->wait_for_pending_js();
} }
$this->wait_for_pending_js();
} }
/** /**

View File

@ -33,7 +33,7 @@ import { CoreCategoryData, CoreCourses, CoreEnrolledCourseData } from '@features
import { CoreCoursesHelper } from '@features/courses/services/courses-helper'; import { CoreCoursesHelper } from '@features/courses/services/courses-helper';
import { AddonCalendarFilterComponent } from '../../components/filter/filter'; import { AddonCalendarFilterComponent } from '../../components/filter/filter';
import moment from 'moment'; import moment from 'moment';
import { Network, NgZone } from '@singletons'; import { NgZone } from '@singletons';
import { CoreNavigator } from '@services/navigator'; import { CoreNavigator } from '@services/navigator';
import { Params } from '@angular/router'; import { Params } from '@angular/router';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
@ -180,7 +180,7 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy {
); );
// Refresh online status when changes. // Refresh online status when changes.
this.onlineObserver = Network.onChange().subscribe(() => { this.onlineObserver = CoreNetwork.onChange().subscribe(() => {
// Execute the callback in the Angular zone, so change detection doesn't stop working. // Execute the callback in the Angular zone, so change detection doesn't stop working.
NgZone.run(() => { NgZone.run(() => {
this.isOnline = CoreNetwork.isOnline(); this.isOnline = CoreNetwork.isOnline();

View File

@ -32,7 +32,7 @@ import { CoreLocalNotifications } from '@services/local-notifications';
import { CoreCourse } from '@features/course/services/course'; import { CoreCourse } from '@features/course/services/course';
import { CoreTimeUtils } from '@services/utils/time'; import { CoreTimeUtils } from '@services/utils/time';
import { CoreGroups } from '@services/groups'; import { CoreGroups } from '@services/groups';
import { Network, NgZone, Translate } from '@singletons'; import { NgZone, Translate } from '@singletons';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { CoreNavigator } from '@services/navigator'; import { CoreNavigator } from '@services/navigator';
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
@ -123,7 +123,7 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy {
); );
// Refresh online status when changes. // Refresh online status when changes.
this.onlineObserver = Network.onChange().subscribe(() => { this.onlineObserver = CoreNetwork.onChange().subscribe(() => {
// Execute the callback in the Angular zone, so change detection doesn't stop working. // Execute the callback in the Angular zone, so change detection doesn't stop working.
NgZone.run(() => { NgZone.run(() => {
this.isOnline = CoreNetwork.isOnline(); this.isOnline = CoreNetwork.isOnline();

View File

@ -23,7 +23,7 @@ import { AddonCalendar, AddonCalendarProvider } from '../../services/calendar';
import { AddonCalendarOffline } from '../../services/calendar-offline'; import { AddonCalendarOffline } from '../../services/calendar-offline';
import { AddonCalendarSync, AddonCalendarSyncProvider } from '../../services/calendar-sync'; import { AddonCalendarSync, AddonCalendarSyncProvider } from '../../services/calendar-sync';
import { AddonCalendarFilter, AddonCalendarHelper } from '../../services/calendar-helper'; import { AddonCalendarFilter, AddonCalendarHelper } from '../../services/calendar-helper';
import { Network, NgZone } from '@singletons'; import { NgZone } from '@singletons';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { CoreEnrolledCourseData } from '@features/courses/services/courses'; import { CoreEnrolledCourseData } from '@features/courses/services/courses';
import { ActivatedRoute, Params } from '@angular/router'; import { ActivatedRoute, Params } from '@angular/router';
@ -153,7 +153,7 @@ export class AddonCalendarIndexPage implements OnInit, OnDestroy {
); );
// Refresh online status when changes. // Refresh online status when changes.
this.onlineObserver = Network.onChange().subscribe(() => { this.onlineObserver = CoreNetwork.onChange().subscribe(() => {
// Execute the callback in the Angular zone, so change detection doesn't stop working. // Execute the callback in the Angular zone, so change detection doesn't stop working.
NgZone.run(() => { NgZone.run(() => {
this.isOnline = CoreNetwork.isOnline(); this.isOnline = CoreNetwork.isOnline();

View File

@ -32,7 +32,8 @@ import { CorePushNotificationsDelegate } from '@features/pushnotifications/servi
import { AddonMessagesPushClickHandler } from './services/handlers/push-click'; import { AddonMessagesPushClickHandler } from './services/handlers/push-click';
import { CoreUserDelegate } from '@features/user/services/user-delegate'; import { CoreUserDelegate } from '@features/user/services/user-delegate';
import { AddonMessagesSendMessageUserHandler } from './services/handlers/user-send-message'; import { AddonMessagesSendMessageUserHandler } from './services/handlers/user-send-message';
import { Network, NgZone } from '@singletons'; import { NgZone } from '@singletons';
import { CoreNetwork } from '@services/network';
import { AddonMessagesSync, AddonMessagesSyncProvider } from './services/messages-sync'; import { AddonMessagesSync, AddonMessagesSyncProvider } from './services/messages-sync';
import { AddonMessagesSyncCronHandler } from './services/handlers/sync-cron'; import { AddonMessagesSyncCronHandler } from './services/handlers/sync-cron';
import { CoreSitePreferencesRoutingModule } from '@features/settings/pages/site/site-routing'; import { CoreSitePreferencesRoutingModule } from '@features/settings/pages/site/site-routing';
@ -86,7 +87,7 @@ const preferencesRoutes: Routes = [
CoreUserDelegate.registerHandler(AddonMessagesSendMessageUserHandler.instance); CoreUserDelegate.registerHandler(AddonMessagesSendMessageUserHandler.instance);
// Sync some discussions when device goes online. // Sync some discussions when device goes online.
Network.onConnect().subscribe(() => { CoreNetwork.onConnect().subscribe(() => {
// Execute the callback in the Angular zone, so change detection doesn't stop working. // Execute the callback in the Angular zone, so change detection doesn't stop working.
NgZone.run(() => { NgZone.run(() => {
AddonMessagesSync.syncAllDiscussions(undefined, true); AddonMessagesSync.syncAllDiscussions(undefined, true);

View File

@ -81,47 +81,16 @@
<ion-icon name="fas-arrow-down" aria-hidden="true"></ion-icon> <ion-icon name="fas-arrow-down" aria-hidden="true"></ion-icon>
</ion-chip> </ion-chip>
<ion-item class="ion-text-wrap addon-message" (longPress)="copyMessage(message)" <core-message [message]="message" [user]="members[message.useridfrom]" (afterRender)="last && scrollToBottom()"
[class.addon-message-mine]="message.useridfrom == currentUserId" [text]="message.text" (onDeleteMessage)="deleteMessage(message, index)" [showDelete]="showDelete"
[class.addon-message-not-mine]="message.useridfrom != currentUserId" [time]="message.timecreated">
[class.addon-message-no-user]="!message.showUserData" </core-message>
[@coreSlideInOut]="message.useridfrom == currentUserId ? '' : 'fromLeft'">
<ion-label>
<!-- User data. -->
<div *ngIf="message.showUserData" class="item-heading addon-message-user">
<core-user-avatar slot="start" [user]="members[message.useridfrom]" [linkProfile]="false" aria-hidden="true">
</core-user-avatar>
<div>{{ members[message.useridfrom].fullname }}</div>
</div>
<div *ngIf="!message.showUserData" class="sr-only">
{{ message.useridfrom == currentUserId
? ('addon.messages.you' | translate)
: members[message.useridfrom].fullname }}
</div>
<!-- Some messages have <p> and some others don't. Add a <p> so they all have same styles. -->
<div class="addon-message-text">
<core-format-text (afterRender)="last && scrollToBottom()" [text]="message.text" contextLevel="system"
[contextInstanceId]="0"></core-format-text>
</div>
</ion-label>
<ion-note *ngIf="!message.pending" slot="end">{{ message.timecreated | coreFormatDate: "strftimetime" }}</ion-note>
<ion-note *ngIf="message.pending" slot="end">
<ion-icon name="fas-clock" [attr.aria-label]="'core.notsent' | translate" role="status"></ion-icon>
</ion-note>
<ion-button fill="clear" *ngIf="!message.sending && showDelete" (click)="deleteMessage(message, index)"
class="addon-messages-delete-button" [@coreSlideInOut]="'fromRight'"
[attr.aria-label]=" 'addon.messages.deletemessage' | translate" slot="end">
<ion-icon name="fas-trash" color="danger" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
<div class="tail" *ngIf="message.showTail"></div>
</ion-item>
</ng-container> </ng-container>
</ion-list> </ion-list>
<core-empty-box *ngIf="!messages || messages.length <= 0" icon="far-comments" <core-empty-box *ngIf="!messages || messages.length <= 0" icon="far-comments"
[message]="'addon.messages.nomessagesfound' | translate"></core-empty-box> [message]="'addon.messages.nomessagesfound' | translate">
</core-empty-box>
</core-loading> </core-loading>
<!-- Scroll bottom. --> <!-- Scroll bottom. -->
<ion-fab slot="fixed" core-fab vertical="bottom" horizontal="end" *ngIf="loaded && newMessages > 0"> <ion-fab slot="fixed" core-fab vertical="bottom" horizontal="end" *ngIf="loaded && newMessages > 0">

View File

@ -37,7 +37,6 @@ import { CoreApp } from '@services/app';
import { CoreInfiniteLoadingComponent } from '@components/infinite-loading/infinite-loading'; import { CoreInfiniteLoadingComponent } from '@components/infinite-loading/infinite-loading';
import { Md5 } from 'ts-md5/dist/md5'; import { Md5 } from 'ts-md5/dist/md5';
import moment from 'moment'; import moment from 'moment';
import { CoreAnimations } from '@components/animations';
import { CoreError } from '@classes/errors/error'; import { CoreError } from '@classes/errors/error';
import { Translate } from '@singletons'; import { Translate } from '@singletons';
import { CoreNavigator } from '@services/navigator'; import { CoreNavigator } from '@services/navigator';
@ -53,7 +52,6 @@ import { CoreDom } from '@singletons/dom';
@Component({ @Component({
selector: 'page-addon-messages-discussion', selector: 'page-addon-messages-discussion',
templateUrl: 'discussion.html', templateUrl: 'discussion.html',
animations: [CoreAnimations.SLIDE_IN_OUT],
styleUrls: ['discussion.scss'], styleUrls: ['discussion.scss'],
}) })
export class AddonMessagesDiscussionPage implements OnInit, OnDestroy, AfterViewInit { export class AddonMessagesDiscussionPage implements OnInit, OnDestroy, AfterViewInit {
@ -305,7 +303,7 @@ export class AddonMessagesDiscussionPage implements OnInit, OnDestroy, AfterView
} else { } else {
if (this.userId) { if (this.userId) {
// Fake the user member info. // Fake the user member info.
promises.push(CoreUser.getProfile(this.userId!).then(async (user) => { promises.push(CoreUser.getProfile(this.userId).then(async (user) => {
this.otherMember = { this.otherMember = {
id: user.id, id: user.id,
fullname: user.fullname, fullname: user.fullname,
@ -524,7 +522,7 @@ export class AddonMessagesDiscussionPage implements OnInit, OnDestroy, AfterView
return; return;
} }
const messages = Array.from(this.hostElement.querySelectorAll('.addon-message-not-mine')) const messages = Array.from(this.hostElement.querySelectorAll('core-message:not(.is-mine)'))
.slice(-this.newMessages) .slice(-this.newMessages)
.reverse(); .reverse();
@ -555,7 +553,7 @@ export class AddonMessagesDiscussionPage implements OnInit, OnDestroy, AfterView
// Try to get the conversationId if we don't have it. // Try to get the conversationId if we don't have it.
if (!conversationId && userId) { if (!conversationId && userId) {
try { try {
if (userId == this.currentUserId && AddonMessages.isSelfConversationEnabled()) { if (userId === this.currentUserId && AddonMessages.isSelfConversationEnabled()) {
fallbackConversation = await AddonMessages.getSelfConversation(); fallbackConversation = await AddonMessages.getSelfConversation();
} else { } else {
fallbackConversation = await AddonMessages.getConversationBetweenUsers(userId, undefined, true); fallbackConversation = await AddonMessages.getConversationBetweenUsers(userId, undefined, true);
@ -563,7 +561,7 @@ export class AddonMessagesDiscussionPage implements OnInit, OnDestroy, AfterView
conversationId = fallbackConversation.id; conversationId = fallbackConversation.id;
} catch (error) { } catch (error) {
// Probably conversation does not exist or user is offline. Try to load offline messages. // Probably conversation does not exist or user is offline. Try to load offline messages.
this.isSelf = userId == this.currentUserId; this.isSelf = userId === this.currentUserId;
const messages = await AddonMessagesOffline.getMessages(userId); const messages = await AddonMessagesOffline.getMessages(userId);
@ -584,11 +582,15 @@ export class AddonMessagesDiscussionPage implements OnInit, OnDestroy, AfterView
} }
} }
if (!conversationId) {
return false;
}
// Retrieve the conversation. Invalidate data first to get the right unreadcount. // Retrieve the conversation. Invalidate data first to get the right unreadcount.
await AddonMessages.invalidateConversation(conversationId!); await AddonMessages.invalidateConversation(conversationId);
try { try {
this.conversation = await AddonMessages.getConversation(conversationId!, undefined, true); this.conversation = await AddonMessages.getConversation(conversationId, undefined, true);
} catch (error) { } catch (error) {
// Get conversation failed, use the fallback one if we have it. // Get conversation failed, use the fallback one if we have it.
if (fallbackConversation) { if (fallbackConversation) {
@ -947,7 +949,6 @@ export class AddonMessagesDiscussionPage implements OnInit, OnDestroy, AfterView
message: AddonMessagesConversationMessageFormatted, message: AddonMessagesConversationMessageFormatted,
index: number, index: number,
): Promise<void> { ): Promise<void> {
const canDeleteAll = this.conversation && this.conversation.candeletemessagesforallusers; const canDeleteAll = this.conversation && this.conversation.candeletemessagesforallusers;
const langKey = message.pending || canDeleteAll || this.isSelf ? 'core.areyousure' : const langKey = message.pending || canDeleteAll || this.isSelf ? 'core.areyousure' :
'addon.messages.deletemessageconfirmation'; 'addon.messages.deletemessageconfirmation';
@ -1099,7 +1100,7 @@ export class AddonMessagesDiscussionPage implements OnInit, OnDestroy, AfterView
*/ */
scrollToFirstUnreadMessage(): void { scrollToFirstUnreadMessage(): void {
if (this.newMessages > 0) { if (this.newMessages > 0) {
const messages = Array.from(this.hostElement.querySelectorAll<HTMLElement>('.addon-message-not-mine')); const messages = Array.from(this.hostElement.querySelectorAll<HTMLElement>('core-message:not(.is-mine)'));
CoreDom.scrollToElement(messages[messages.length - this.newMessages]); CoreDom.scrollToElement(messages[messages.length - this.newMessages]);
} }

View File

@ -106,7 +106,6 @@ Feature: Test basic usage of messages in app
And I should find "hi" in the app And I should find "hi" in the app
And I should find "byee" in the app And I should find "byee" in the app
# TODO Fix this test in all Moodle versions
Scenario: User profile: send message, add/remove contact Scenario: User profile: send message, add/remove contact
Given I entered the app as "teacher1" Given I entered the app as "teacher1"
When I press "Messages" in the app When I press "Messages" in the app

View File

@ -16,7 +16,7 @@ import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { CoreNetwork } from '@services/network'; import { CoreNetwork } from '@services/network';
import { CoreSites } from '@services/sites'; import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreDomUtils } from '@services/utils/dom';
import { ModalController, Network, NgZone } from '@singletons'; import { ModalController, NgZone } from '@singletons';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { AddonModChat, AddonModChatUser } from '../../services/chat'; import { AddonModChat, AddonModChatUser } from '../../services/chat';
@ -42,7 +42,7 @@ export class AddonModChatUsersModalComponent implements OnInit, OnDestroy {
constructor() { constructor() {
this.isOnline = CoreNetwork.isOnline(); this.isOnline = CoreNetwork.isOnline();
this.currentUserId = CoreSites.getCurrentSiteUserId(); this.currentUserId = CoreSites.getCurrentSiteUserId();
this.onlineSubscription = Network.onChange().subscribe(() => { this.onlineSubscription = CoreNetwork.onChange().subscribe(() => {
// Execute the callback in the Angular zone, so change detection doesn't stop working. // Execute the callback in the Angular zone, so change detection doesn't stop working.
NgZone.run(() => { NgZone.run(() => {
this.isOnline = CoreNetwork.isOnline(); this.isOnline = CoreNetwork.isOnline();

View File

@ -81,27 +81,10 @@
</ion-badge> </ion-badge>
</div> </div>
<ion-item *ngIf="!message.special" class="ion-text-wrap addon-message" <core-message *ngIf="!message.special" [message]="message" [user]="message" [text]="message.message"
[class.addon-message-mine]="message.userid == currentUserId" [time]="message.timestamp * 1000" (afterRender)="last && scrollToBottom()" contextLevel="module" [instanceId]="cmId"
[class.addon-message-not-mine]="message.userid != currentUserId" [class.addon-message-no-user]="!message.showUserData" [courseId]="courseId">
[@coreSlideInOut]="message.userid == currentUserId ? '' : 'fromLeft'"> </core-message>
<ion-label>
<!-- User data. -->
<h2 class="addon-message-user" *ngIf="message.showUserData">
<core-user-avatar slot="start" [user]="message" [linkProfile]="false">
</core-user-avatar>
<div>{{ message.userfullname }}</div>
</h2>
<div class="addon-message-text">
<core-format-text [text]="message.message" contextLevel="module" [contextInstanceId]="cmId"
[courseId]="courseId" (afterRender)="last && scrollToBottom()">
</core-format-text>
</div>
</ion-label>
<ion-note slot="end">{{ message.timestamp * 1000 | coreFormatDate: "strftimetime" }}</ion-note>
<div class="tail" *ngIf="message.showTail"></div>
</ion-item>
</ng-container> </ng-container>
</ion-list> </ion-list>

View File

@ -13,7 +13,6 @@
// limitations under the License. // limitations under the License.
import { Component, ViewChild, OnInit, OnDestroy } from '@angular/core'; import { Component, ViewChild, OnInit, OnDestroy } from '@angular/core';
import { CoreAnimations } from '@components/animations';
import { CoreSendMessageFormComponent } from '@components/send-message-form/send-message-form'; import { CoreSendMessageFormComponent } from '@components/send-message-form/send-message-form';
import { CanLeave } from '@guards/can-leave'; import { CanLeave } from '@guards/can-leave';
import { IonContent } from '@ionic/angular'; import { IonContent } from '@ionic/angular';
@ -23,7 +22,7 @@ import { CoreNavigator } from '@services/navigator';
import { CoreSites } from '@services/sites'; import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
import { Network, NgZone, Translate } from '@singletons'; import { NgZone, Translate } from '@singletons';
import { CoreEvents } from '@singletons/events'; import { CoreEvents } from '@singletons/events';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { AddonModChatUsersModalComponent, AddonModChatUsersModalResult } from '../../components/users-modal/users-modal'; import { AddonModChatUsersModalComponent, AddonModChatUsersModalResult } from '../../components/users-modal/users-modal';
@ -36,7 +35,6 @@ import { AddonModChatFormattedMessage, AddonModChatHelper } from '../../services
@Component({ @Component({
selector: 'page-addon-mod-chat-chat', selector: 'page-addon-mod-chat-chat',
templateUrl: 'chat.html', templateUrl: 'chat.html',
animations: [CoreAnimations.SLIDE_IN_OUT],
styleUrls: ['chat.scss'], styleUrls: ['chat.scss'],
}) })
export class AddonModChatChatPage implements OnInit, OnDestroy, CanLeave { export class AddonModChatChatPage implements OnInit, OnDestroy, CanLeave {
@ -67,7 +65,7 @@ export class AddonModChatChatPage implements OnInit, OnDestroy, CanLeave {
constructor() { constructor() {
this.currentUserId = CoreSites.getCurrentSiteUserId(); this.currentUserId = CoreSites.getCurrentSiteUserId();
this.isOnline = CoreNetwork.isOnline(); this.isOnline = CoreNetwork.isOnline();
this.onlineSubscription = Network.onChange().subscribe(() => { this.onlineSubscription = CoreNetwork.onChange().subscribe(() => {
// Execute the callback in the Angular zone, so change detection doesn't stop working. // Execute the callback in the Angular zone, so change detection doesn't stop working.
NgZone.run(() => { NgZone.run(() => {
this.isOnline = CoreNetwork.isOnline(); this.isOnline = CoreNetwork.isOnline();

View File

@ -75,26 +75,9 @@
</ion-badge> </ion-badge>
</div> </div>
<ion-item *ngIf="!message.special" class="ion-text-wrap addon-message" <core-message *ngIf="!message.special" [message]="message" [user]="message" [text]="message.message"
[class.addon-message-mine]="message.userid == currentUserId" [time]="message.timestamp * 1000" contextLevel="module" [instanceId]="cmId" [courseId]="courseId">
[class.addon-message-not-mine]="message.userid != currentUserId" [class.addon-message-no-user]="!message.showUserData"> </core-message>
<ion-label>
<!-- User data. -->
<h2 class="addon-message-user">
<core-user-avatar slot="start" [user]="message" [linkProfile]="false" *ngIf="message.showUserData">
</core-user-avatar>
<div *ngIf="message.showUserData">{{ message.userfullname }}</div>
</h2>
<div class="addon-message-text">
<core-format-text [text]="message.message" contextLevel="module" [contextInstanceId]="cmId"
[courseId]="courseId">
</core-format-text>
</div>
</ion-label>
<ion-note slot="end">{{ message.timestamp * 1000 | coreFormatDate: "strftimetime" }}</ion-note>
<div class="tail" *ngIf="message.showTail"></div>
</ion-item>
</ng-container> </ng-container>
</ion-list> </ion-list>
</core-loading> </core-loading>

View File

@ -0,0 +1,210 @@
@mod @mod_data @app @javascript
Feature: Users can manage entries in database activities
In order to populate databases
As a user
I need to add and manage entries to databases
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| student1 | Student | 1 | student1@example.com |
| student2 | Student | 2 | student2@example.com |
| teacher1 | Teacher | 1 | teacher1@example.com |
And the following "courses" exist:
| fullname | shortname | category |
| Course 1 | C1 | 0 |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
| student1 | C1 | student |
| student2 | C1 | student |
And the following "activities" exist:
| activity | name | intro | course | idnumber |
| data | Web links | Useful links | C1 | data1 |
And I log in as "teacher1"
And I am on "Course 1" course homepage
# TODO Create and use a generator for database fields.
And I add a "Text input" field to "Web links" database and I fill the form with:
| Field name | URL |
| Field description | URL link |
And I add a "Text input" field to "Web links" database and I fill the form with:
| Field name | Description |
| Field description | Link description |
And I log out
Scenario: Create entry
Given I entered the data activity "Web links" on course "Course 1" as "student1" in the app
Then I should find "No entries in database" in the app
When I press "Add entries" in the app
And I set the following fields to these values in the app:
| URL | https://moodle.org/ |
| Description | Moodle community site |
And I press "Save" near "Web links" in the app
Then I should find "https://moodle.org/" in the app
And I should find "Moodle community site" in the app
Scenario: Browse entry
Given I entered the data activity "Web links" on course "Course 1" as "student1" in the app
# TODO Create and use a generator for database entries.
And I press "Add entries" in the app
And I set the following fields to these values in the app:
| URL | https://moodle.org/ |
| Description | Moodle community site |
And I press "Save" near "Web links" in the app
And I entered the data activity "Web links" on course "Course 1" as "student2" in the app
And I press "Add entries" in the app
And I set the following fields to these values in the app:
| URL | https://moodlecloud.com/ |
| Description | Moodle Cloud |
And I press "Save" near "Web links" in the app
And I press "More" near "Moodle community site" in the app
Then I should find "Moodle community site" in the app
And I should not find "Next" in the app
And I should find "Previous" in the app
And I press "Previous" in the app
And I should find "Moodle Cloud" in the app
And I should find "Next" in the app
And I should not find "Previous" in the app
And I press "Next" in the app
And I should find "Moodle community site" in the app
And I should not find "Moodle Cloud" in the app
And I press the back button in the app
And I should find "Moodle community site" in the app
And I should find "Moodle Cloud" in the app
Scenario: Students can not edit or delete other user's entries from list and single view in the app
Given I entered the data activity "Web links" on course "Course 1" as "student1" in the app
And I press "Add entries" in the app
And I set the following fields to these values in the app:
| URL | https://moodle.org/ |
| Description | Moodle community site |
And I press "Save" near "Web links" in the app
And I entered the course "Course 1" as "student2" in the app
When I press "Web links" near "General" in the app
Then "Edit" "link" should not exist
And "Delete" "link" should not exist
And I press "More" in the app
And "Edit" "link" should not exist
And "Delete" "link" should not exist
Scenario: Delete entry (student) & Update entry (student)
Given I entered the data activity "Web links" on course "Course 1" as "student1" in the app
And I press "Add entries" in the app
And I set the following fields to these values in the app:
| URL | https://moodle.org/ |
| Description | Moodle community site |
And I press "Save" near "Web links" in the app
# Edit the entry from list view.
When I press "Edit" in the app
And I set the following fields to these values in the app:
| URL | https://moodlecloud.com/ |
| Description | Moodle Cloud |
And I press "Save" near "Web links" in the app
Then I should not find "https://moodle.org/" in the app
And I should not find "Moodle community site" in the app
And I should find "https://moodlecloud.com/" in the app
And I should find "Moodle Cloud" in the app
# Delete the entry from list view.
When I press "Delete" in the app
Then I should find "Are you sure you want to delete this entry?" in the app
And I press "Cancel" in the app
And I should find "Moodle Cloud" in the app
When I press "Delete" in the app
Then I should find "Are you sure you want to delete this entry?" in the app
And I press "Delete" in the app
And I should not find "Moodle Cloud" in the app
# Repeat again with single view.
Given I press "Add entries" in the app
And I set the following fields to these values in the app:
| URL | https://moodle.org/ |
| Description | Moodle community site |
And I press "Save" near "Web links" in the app
# Edit the entry from single view.
When I press "More" in the app
And I press "Edit" in the app
And I set the following fields to these values in the app:
| URL | https://moodlecloud.com/ |
| Description | Moodle Cloud |
And I press "Save" near "Web links" in the app
Then I should not find "https://moodle.org/" in the app
And I should not find "Moodle community site" in the app
And I should find "https://moodlecloud.com/" in the app
And I should find "Moodle Cloud" in the app
# Delete the entry from list view.
When I press "Delete" in the app
Then I should find "Are you sure you want to delete this entry?" in the app
And I press "Cancel" in the app
And I should find "Moodle Cloud" in the app
When I press "Delete" in the app
Then I should find "Are you sure you want to delete this entry?" in the app
And I press "Delete" in the app
And I should not find "Moodle Cloud" in the app
And I should find "No entries in database" in the app
Scenario: Delete entry (teacher) & Update entry (teacher)
Given I entered the data activity "Web links" on course "Course 1" as "student1" in the app
And I press "Add entries" in the app
And I set the following fields to these values in the app:
| URL | https://moodle.org/ |
| Description | Moodle community site |
And I press "Save" near "Web links" in the app
And I press "Add entries" in the app
And I set the following fields to these values in the app:
| URL | https://telegram.org/ |
| Description | Telegram |
And I press "Save" near "Web links" in the app
And I entered the course "Course 1" as "teacher1" in the app
When I press "Web links" near "General" in the app
Then I should find "https://moodle.org/" in the app
And I should find "Moodle community site" in the app
# Edit the entry from list view.
When I press "Edit" near "Moodle community site" in the app
And I set the following fields to these values in the app:
| URL | https://moodlecloud.com/ |
| Description | Moodle Cloud |
And I press "Save" near "Web links" in the app
Then I should not find "https://moodle.org/" in the app
And I should not find "Moodle community site" in the app
And I should find "https://moodlecloud.com/" in the app
And I should find "Moodle Cloud" in the app
# Delete the entry from list view.
When I press "Delete" near "Moodle Cloud" in the app
Then I should find "Are you sure you want to delete this entry?" in the app
And I press "Cancel" in the app
And I should find "Moodle Cloud" in the app
When I press "Delete" near "Moodle Cloud" in the app
Then I should find "Are you sure you want to delete this entry?" in the app
And I press "Delete" in the app
And I should not find "Moodle Cloud" in the app
# Edit the entry from single view.
When I press "More" in the app
And I should find "https://telegram.org/" in the app
And I should find "Telegram" in the app
And I press "Edit" in the app
And I set the following fields to these values in the app:
| URL | https://moodlecloud.com/ |
| Description | Moodle Cloud |
And I press "Save" near "Web links" in the app
Then I should not find "https://telegram.org/" in the app
And I should not find "Telegram" in the app
And I should find "https://moodlecloud.com/" in the app
And I should find "Moodle Cloud" in the app
# Delete the entry from single view.
When I press "Delete" in the app
Then I should find "Are you sure you want to delete this entry?" in the app
And I press "Cancel" in the app
And I should find "Moodle Cloud" in the app
When I press "Delete" in the app
Then I should find "Are you sure you want to delete this entry?" in the app
And I press "Delete" in the app
And I should not find "Moodle Cloud" in the app

View File

@ -0,0 +1,128 @@
@mod @mod_data @app @javascript
Feature: Users can store entries in database activities when offline and sync when online
In order to populate databases while offline
As a user
I need to add and manage entries to databases and sync then when online
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| student1 | Student | 1 | student1@example.com |
| student2 | Student | 2 | student2@example.com |
| teacher1 | Teacher | 1 | teacher1@example.com |
And the following "courses" exist:
| fullname | shortname | category |
| Course 1 | C1 | 0 |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
| student1 | C1 | student |
| student2 | C1 | student |
And the following "activities" exist:
| activity | name | intro | course | idnumber |
| data | Web links | Useful links | C1 | data1 |
And I log in as "teacher1"
And I am on "Course 1" course homepage
And I add a "Text input" field to "Web links" database and I fill the form with:
| Field name | URL |
| Field description | URL link |
And I add a "Text input" field to "Web links" database and I fill the form with:
| Field name | Description |
| Field description | Link description |
And I log out
Scenario: Create entry (offline)
Given I entered the data activity "Web links" on course "Course 1" as "student1" in the app
And I switch offline mode to "true"
And I should find "No entries in database" in the app
When I press "Add entries" in the app
And I set the following fields to these values in the app:
| URL | https://moodle.org/ |
| Description | Moodle community site |
And I press "Save" near "Web links" in the app
Then I should find "https://moodle.org/" in the app
And I should find "Moodle community site" in the app
And I should find "This Database has offline data to be synchronised" in the app
And I press the back button in the app
And I switch offline mode to "false"
And I press "Web links" near "General" in the app
And I should find "https://moodle.org/" in the app
And I should find "Moodle community site" in the app
And I should not find "This Database has offline data to be synchronised" in the app
Scenario: Update entry (offline) & Delete entry (offline)
Given I entered the data activity "Web links" on course "Course 1" as "student1" in the app
And I should find "No entries in database" in the app
And I press "Add entries" in the app
And I set the following fields to these values in the app:
| URL | https://moodle.org/ |
| Description | Moodle community site |
And I press "Save" near "Web links" in the app
And I should find "https://moodle.org/" in the app
And I should find "Moodle community site" in the app
And I press "Information" in the app
And I press "Download" in the app
And I wait until the page is ready
And I switch offline mode to "true"
When I press "Edit" in the app
And I set the following fields to these values in the app:
| URL | https://moodlecloud.com/ |
| Description | Moodle Cloud |
And I press "Save" near "Web links" in the app
Then I should not find "https://moodle.org/" in the app
And I should not find "Moodle community site" in the app
And I should find "https://moodlecloud.com/" in the app
And I should find "Moodle Cloud" in the app
And I should find "This Database has offline data to be synchronised" in the app
And I press the back button in the app
And I switch offline mode to "false"
And I press "Web links" near "General" in the app
And I should not find "https://moodle.org/" in the app
And I should not find "Moodle community site" in the app
And I should find "https://moodlecloud.com/" in the app
And I should find "Moodle Cloud" in the app
And I should not find "This Database has offline data to be synchronised" in the app
And I press "Information" in the app
And I press "Refresh" in the app
And I wait until the page is ready
And I switch offline mode to "true"
And I press "Delete" in the app
And I should find "Are you sure you want to delete this entry?" in the app
And I press "Delete" in the app
And I should find "https://moodlecloud.com/" in the app
And I should find "Moodle Cloud" in the app
And I should find "This Database has offline data to be synchronised" in the app
And I press the back button in the app
And I switch offline mode to "false"
And I press "Web links" near "General" in the app
And I should not find "https://moodlecloud.com/" in the app
And I should not find "Moodle Cloud" in the app
And I should not find "This Database has offline data to be synchronised" in the app
Scenario: Students can undo deleting entries to a database in the app while offline
Given I entered the data activity "Web links" on course "Course 1" as "student1" in the app
And I should find "No entries in database" in the app
And I press "Add entries" in the app
And I set the following fields to these values in the app:
| URL | https://moodle.org/ |
| Description | Moodle community site |
And I press "Save" near "Web links" in the app
And I should find "https://moodle.org/" in the app
And I should find "Moodle community site" in the app
And I press "Information" in the app
And I press "Download" in the app
And I wait until the page is ready
When I switch offline mode to "true"
And I press "Delete" in the app
And I should find "Are you sure you want to delete this entry?" in the app
And I press "Delete" in the app
And I should find "https://moodle.org/" in the app
And I should find "Moodle community site" in the app
And I should find "This Database has offline data to be synchronised" in the app
And I press "Restore" in the app
And I press the back button in the app
And I switch offline mode to "false"
And I press "Web links" near "General" in the app
Then I should find "https://moodle.org/" in the app
And I should find "Moodle community site" in the app
And I should not find "This Database has offline data to be synchronised" in the app

View File

@ -24,7 +24,7 @@ import { CoreNavigator } from '@services/navigator';
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
import { Network, NgZone, Translate } from '@singletons'; import { NgZone, Translate } from '@singletons';
import { CoreEvents } from '@singletons/events'; import { CoreEvents } from '@singletons/events';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { import {
@ -80,7 +80,7 @@ export class AddonModFeedbackFormPage implements OnInit, OnDestroy, CanLeave {
this.currentSite = CoreSites.getRequiredCurrentSite(); this.currentSite = CoreSites.getRequiredCurrentSite();
// Refresh online status when changes. // Refresh online status when changes.
this.onlineObserver = Network.onChange().subscribe(() => { this.onlineObserver = CoreNetwork.onChange().subscribe(() => {
// Execute the callback in the Angular zone, so change detection doesn't stop working. // Execute the callback in the Angular zone, so change detection doesn't stop working.
NgZone.run(() => { NgZone.run(() => {
this.offline = !CoreNetwork.isOnline(); this.offline = !CoreNetwork.isOnline();

View File

@ -30,7 +30,7 @@ import { CoreScreen } from '@services/screen';
import { CoreSites } from '@services/sites'; import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
import { Network, NgZone, Translate } from '@singletons'; import { NgZone, Translate } from '@singletons';
import { CoreArray } from '@singletons/array'; import { CoreArray } from '@singletons/array';
import { CoreDom } from '@singletons/dom'; import { CoreDom } from '@singletons/dom';
import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreEventObserver, CoreEvents } from '@singletons/events';
@ -166,7 +166,7 @@ export class AddonModForumDiscussionPage implements OnInit, AfterViewInit, OnDes
this.isOnline = CoreNetwork.isOnline(); this.isOnline = CoreNetwork.isOnline();
this.externalUrl = CoreSites.getCurrentSite()?.createSiteUrl('/mod/forum/discuss.php', { d: this.discussionId.toString() }); this.externalUrl = CoreSites.getCurrentSite()?.createSiteUrl('/mod/forum/discuss.php', { d: this.discussionId.toString() });
this.onlineObserver = Network.onChange().subscribe(() => { this.onlineObserver = CoreNetwork.onChange().subscribe(() => {
// Execute the callback in the Angular zone, so change detection doesn't stop working. // Execute the callback in the Angular zone, so change detection doesn't stop working.
NgZone.run(() => { NgZone.run(() => {
this.isOnline = CoreNetwork.isOnline(); this.isOnline = CoreNetwork.isOnline();

View File

@ -27,8 +27,9 @@ Feature: Test basic usage of forum activity in app
Scenario: Create new discussion Scenario: Create new discussion
Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app
When I press "Add discussion topic" in the app When I press "Add discussion topic" in the app
And I set the field "Subject" to "My happy subject" in the app And I set the following fields to these values in the app:
And I set the field "Message" to "An awesome message" in the app | Subject | My happy subject |
| Message | An awesome message |
And I press "Post to forum" in the app And I press "Post to forum" in the app
Then I should find "My happy subject" in the app Then I should find "My happy subject" in the app
@ -38,8 +39,9 @@ Feature: Test basic usage of forum activity in app
Scenario: Reply a post Scenario: Reply a post
Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app
When I press "Add discussion topic" in the app When I press "Add discussion topic" in the app
And I set the field "Subject" to "DiscussionSubject" in the app And I set the following fields to these values in the app:
And I set the field "Message" to "DiscussionMessage" in the app | Subject | DiscussionSubject |
| Message | DiscussionMessage |
And I press "Post to forum" in the app And I press "Post to forum" in the app
And I press "DiscussionSubject" in the app And I press "DiscussionSubject" in the app
Then I should find "Reply" in the app Then I should find "Reply" in the app
@ -53,12 +55,14 @@ Feature: Test basic usage of forum activity in app
Scenario: Star and pin discussions (student) Scenario: Star and pin discussions (student)
Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app
When I press "Add discussion topic" in the app When I press "Add discussion topic" in the app
And I set the field "Subject" to "starred subject" in the app And I set the following fields to these values in the app:
And I set the field "Message" to "starred message" in the app | Subject | starred subject |
| Message | starred message |
And I press "Post to forum" in the app And I press "Post to forum" in the app
And I press "Add discussion topic" in the app And I press "Add discussion topic" in the app
And I set the field "Subject" to "normal subject" in the app And I set the following fields to these values in the app:
And I set the field "Message" to "normal message" in the app | Subject | normal subject |
| Message | normal message |
And I press "Post to forum" in the app And I press "Post to forum" in the app
And I press "starred subject" in the app And I press "starred subject" in the app
Then I should find "starred message" in the app Then I should find "starred message" in the app
@ -86,16 +90,19 @@ Feature: Test basic usage of forum activity in app
Scenario: Star and pin discussions (teacher) Scenario: Star and pin discussions (teacher)
Given I entered the forum activity "Test forum name" on course "Course 1" as "teacher1" in the app Given I entered the forum activity "Test forum name" on course "Course 1" as "teacher1" in the app
When I press "Add discussion topic" in the app When I press "Add discussion topic" in the app
And I set the field "Subject" to "Auto-test star" in the app And I set the following fields to these values in the app:
And I set the field "Message" to "Auto-test star message" in the app | Subject | Auto-test star |
| Message | Auto-test star message |
And I press "Post to forum" in the app And I press "Post to forum" in the app
And I press "Add discussion topic" in the app And I press "Add discussion topic" in the app
And I set the field "Subject" to "Auto-test pin" in the app And I set the following fields to these values in the app:
And I set the field "Message" to "Auto-test pin message" in the app | Subject | Auto-test pin |
| Message | Auto-test pin message |
And I press "Post to forum" in the app And I press "Post to forum" in the app
And I press "Add discussion topic" in the app And I press "Add discussion topic" in the app
And I set the field "Subject" to "Auto-test plain" in the app And I set the following fields to these values in the app:
And I set the field "Message" to "Auto-test plain message" in the app | Subject | Auto-test plain |
| Message | Auto-test plain message |
And I press "Post to forum" in the app And I press "Post to forum" in the app
And I press "Display options" near "Auto-test star" in the app And I press "Display options" near "Auto-test star" in the app
And I press "Star this discussion" in the app And I press "Star this discussion" in the app
@ -115,8 +122,9 @@ Feature: Test basic usage of forum activity in app
Scenario: Edit a not sent reply offline Scenario: Edit a not sent reply offline
Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app
When I press "Add discussion topic" in the app When I press "Add discussion topic" in the app
And I set the field "Subject" to "Auto-test" in the app And I set the following fields to these values in the app:
And I set the field "Message" to "Auto-test message" in the app | Subject | Auto-test |
| Message | Auto-test message |
And I press "Post to forum" in the app And I press "Post to forum" in the app
And I press "Auto-test" near "Sort by last post creation date in descending order" in the app And I press "Auto-test" near "Sort by last post creation date in descending order" in the app
And I should find "Reply" in the app And I should find "Reply" in the app
@ -148,8 +156,9 @@ Feature: Test basic usage of forum activity in app
Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app
When I switch offline mode to "true" When I switch offline mode to "true"
And I press "Add discussion topic" in the app And I press "Add discussion topic" in the app
And I set the field "Subject" to "Auto-test" in the app And I set the following fields to these values in the app:
And I set the field "Message" to "Auto-test message" in the app | Subject | Auto-test |
| Message | Auto-test message |
And I press "Post to forum" in the app And I press "Post to forum" in the app
And I press "Auto-test" in the app And I press "Auto-test" in the app
And I set the field "Message" to "Auto-test message edited" in the app And I set the field "Message" to "Auto-test message edited" in the app
@ -169,8 +178,9 @@ Feature: Test basic usage of forum activity in app
Scenario: Edit a forum post (only online) Scenario: Edit a forum post (only online)
Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app
When I press "Add discussion topic" in the app When I press "Add discussion topic" in the app
And I set the field "Subject" to "Auto-test" in the app And I set the following fields to these values in the app:
And I set the field "Message" to "Auto-test message" in the app | Subject | Auto-test |
| Message | Auto-test message |
And I press "Post to forum" in the app And I press "Post to forum" in the app
Then I should find "Auto-test" in the app Then I should find "Auto-test" in the app
@ -194,8 +204,9 @@ Feature: Test basic usage of forum activity in app
Scenario: Delete a forum post (only online) Scenario: Delete a forum post (only online)
Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app
When I press "Add discussion topic" in the app When I press "Add discussion topic" in the app
And I set the field "Subject" to "Auto-test" in the app And I set the following fields to these values in the app:
And I set the field "Message" to "Auto-test message" in the app | Subject | Auto-test |
| Message | Auto-test message |
And I press "Post to forum" in the app And I press "Post to forum" in the app
Then I should find "Auto-test" in the app Then I should find "Auto-test" in the app
@ -230,8 +241,9 @@ Feature: Test basic usage of forum activity in app
Scenario: Add/view ratings Scenario: Add/view ratings
Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app
When I press "Add discussion topic" in the app When I press "Add discussion topic" in the app
And I set the field "Subject" to "Auto-test" in the app And I set the following fields to these values in the app:
And I set the field "Message" to "Auto-test message" in the app | Subject | Auto-test |
| Message | Auto-test message |
And I press "Post to forum" in the app And I press "Post to forum" in the app
And I press "Auto-test" in the app And I press "Auto-test" in the app
Then I should find "Reply" in the app Then I should find "Reply" in the app
@ -276,8 +288,9 @@ Feature: Test basic usage of forum activity in app
Scenario: Reply a post offline Scenario: Reply a post offline
Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app
When I press "Add discussion topic" in the app When I press "Add discussion topic" in the app
And I set the field "Subject" to "DiscussionSubject" in the app And I set the following fields to these values in the app:
And I set the field "Message" to "DiscussionMessage" in the app | Subject | DiscussionSubject |
| Message | DiscussionMessage |
And I press "Post to forum" in the app And I press "Post to forum" in the app
And I press the back button in the app And I press the back button in the app
And I press "Course downloads" in the app And I press "Course downloads" in the app
@ -306,8 +319,9 @@ Feature: Test basic usage of forum activity in app
Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app
When I switch offline mode to "true" When I switch offline mode to "true"
And I press "Add discussion topic" in the app And I press "Add discussion topic" in the app
And I set the field "Subject" to "DiscussionSubject" in the app And I set the following fields to these values in the app:
And I set the field "Message" to "DiscussionMessage" in the app | Subject | DiscussionSubject |
| Message | DiscussionMessage |
And I press "Post to forum" in the app And I press "Post to forum" in the app
Then I should find "DiscussionSubject" in the app Then I should find "DiscussionSubject" in the app
And I should find "Not sent" in the app And I should find "Not sent" in the app
@ -328,8 +342,9 @@ Feature: Test basic usage of forum activity in app
Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app
When I switch offline mode to "true" When I switch offline mode to "true"
And I press "Add discussion topic" in the app And I press "Add discussion topic" in the app
And I set the field "Subject" to "DiscussionSubject" in the app And I set the following fields to these values in the app:
And I set the field "Message" to "DiscussionMessage" in the app | Subject | DiscussionSubject |
| Message | DiscussionMessage |
And I press "Post to forum" in the app And I press "Post to forum" in the app
Then I should find "DiscussionSubject" in the app Then I should find "DiscussionSubject" in the app
And I should find "Not sent" in the app And I should find "Not sent" in the app
@ -349,8 +364,9 @@ Feature: Test basic usage of forum activity in app
Scenario: Prefetch Scenario: Prefetch
Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app
When I press "Add discussion topic" in the app When I press "Add discussion topic" in the app
And I set the field "Subject" to "DiscussionSubject 1" in the app And I set the following fields to these values in the app:
And I set the field "Message" to "DiscussionMessage 1" in the app | Subject | DiscussionSubject 1 |
| Message | DiscussionMessage 1 |
And I press "Post to forum" in the app And I press "Post to forum" in the app
Then I should find "DiscussionSubject 1" in the app Then I should find "DiscussionSubject 1" in the app
@ -362,8 +378,9 @@ Feature: Test basic usage of forum activity in app
When I press "Test forum name" in the app When I press "Test forum name" in the app
And I press "Add discussion topic" in the app And I press "Add discussion topic" in the app
And I set the field "Subject" to "DiscussionSubject 2" in the app And I set the following fields to these values in the app:
And I set the field "Message" to "DiscussionMessage 2" in the app | Subject | DiscussionSubject 2 |
| Message | DiscussionMessage 2 |
And I press "Post to forum" in the app And I press "Post to forum" in the app
Then I should find "DiscussionSubject 1" in the app Then I should find "DiscussionSubject 1" in the app
And I should find "DiscussionSubject 2" in the app And I should find "DiscussionSubject 2" in the app

View File

@ -104,20 +104,23 @@ Feature: Test forum navigation
When I press the back button in the app When I press the back button in the app
And I press "Add discussion topic" in the app And I press "Add discussion topic" in the app
And I switch offline mode to "true" And I switch offline mode to "true"
And I set the field "Subject" to "Offline discussion 1" in the app And I set the following fields to these values in the app:
And I set the field "Message" to "Offline discussion 1 message" in the app | Subject | Offline discussion 1 |
| Message | Offline discussion 1 message |
And I press "Post to forum" in the app And I press "Post to forum" in the app
And I press "Add discussion topic" in the app And I press "Add discussion topic" in the app
And I set the field "Subject" to "Offline discussion 2" in the app And I set the following fields to these values in the app:
And I set the field "Message" to "Offline discussion 2 message" in the app | Subject | Offline discussion 2 |
| Message | Offline discussion 2 message |
And I press "Post to forum" in the app And I press "Post to forum" in the app
Then I should find "Not sent" in the app Then I should find "Not sent" in the app
And I should find "Offline discussion 1" in the app And I should find "Offline discussion 1" in the app
And I should find "Offline discussion 2" in the app And I should find "Offline discussion 2" in the app
When I press "Offline discussion 2" in the app When I press "Offline discussion 2" in the app
And I set the field "Subject" to "Offline discussion 3" in the app And I set the following fields to these values in the app:
And I set the field "Message" to "Offline discussion 3 message" in the app | Subject | Offline discussion 3 |
| Message | Offline discussion 3 message |
And I press "Post to forum" in the app And I press "Post to forum" in the app
Then I should find "Not sent" in the app Then I should find "Not sent" in the app
And I should find "Offline discussion 1" in the app And I should find "Offline discussion 1" in the app
@ -197,20 +200,23 @@ Feature: Test forum navigation
# Offline # Offline
When I press "Add discussion topic" in the app When I press "Add discussion topic" in the app
And I switch offline mode to "true" And I switch offline mode to "true"
And I set the field "Subject" to "Offline discussion 1" in the app And I set the following fields to these values in the app:
And I set the field "Message" to "Offline discussion 1 message" in the app | Subject | Offline discussion 1 |
| Message | Offline discussion 1 message |
And I press "Post to forum" in the app And I press "Post to forum" in the app
And I press "Add discussion topic" in the app And I press "Add discussion topic" in the app
And I set the field "Subject" to "Offline discussion 2" in the app And I set the following fields to these values in the app:
And I set the field "Message" to "Offline discussion 2 message" in the app | Subject | Offline discussion 2 |
| Message | Offline discussion 2 message |
And I press "Post to forum" in the app And I press "Post to forum" in the app
Then I should find "Not sent" in the app Then I should find "Not sent" in the app
And I should find "Offline discussion 1" in the app And I should find "Offline discussion 1" in the app
And I should find "Offline discussion 2" in the app And I should find "Offline discussion 2" in the app
When I press "Offline discussion 2" in the app When I press "Offline discussion 2" in the app
And I set the field "Subject" to "Offline discussion 3" in the app And I set the following fields to these values in the app:
And I set the field "Message" to "Offline discussion 3 message" in the app | Subject | Offline discussion 3 |
| Message | Offline discussion 3 message |
And I press "Post to forum" in the app And I press "Post to forum" in the app
Then I should find "Not sent" in the app Then I should find "Not sent" in the app
And I should find "Offline discussion 1" in the app And I should find "Offline discussion 1" in the app

View File

@ -28,8 +28,7 @@
<core-loading [hideUntil]="!showLoading"> <core-loading [hideUntil]="!showLoading">
<!-- Activity info. --> <!-- Activity info. -->
<core-course-module-info *ngIf="!isSearch" [module]="module" [description]="description" [component]="component" <core-course-module-info *ngIf="!isSearch" [module]="module" [description]="description" [component]="component"
[componentId]="componentId" [courseId]="courseId" [hasDataToSync]="hasOffline || hasOfflineRatings" [componentId]="componentId" [courseId]="courseId" [hasDataToSync]="hasOffline" (completionChanged)="onCompletionChange()">
(completionChanged)="onCompletionChange()">
</core-course-module-info> </core-course-module-info>
<ion-list *ngIf="!isSearch && entries && entries.offlineEntries.length > 0"> <ion-list *ngIf="!isSearch && entries && entries.offlineEntries.length > 0">

View File

@ -72,8 +72,9 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
loadMoreError = false; loadMoreError = false;
loadingMessage: string; loadingMessage: string;
promisedEntries: CorePromisedValue<AddonModGlossaryEntriesManager>; promisedEntries: CorePromisedValue<AddonModGlossaryEntriesManager>;
hasOfflineRatings = false;
protected hasOfflineEntries = false;
protected hasOfflineRatings = false;
protected syncEventName = AddonModGlossarySyncProvider.AUTO_SYNCED; protected syncEventName = AddonModGlossarySyncProvider.AUTO_SYNCED;
protected addEntryObserver?: CoreEventObserver; protected addEntryObserver?: CoreEventObserver;
protected fetchedEntriesCanLoadMore = false; protected fetchedEntriesCanLoadMore = false;
@ -128,7 +129,10 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
this.promisedEntries.resolve(new AddonModGlossaryEntriesManager(source, this)); this.promisedEntries.resolve(new AddonModGlossaryEntriesManager(source, this));
this.sourceUnsubscribe = source.addListener({ this.sourceUnsubscribe = source.addListener({
onItemsUpdated: items => this.hasOffline = !!items.find(item => source.isOfflineEntry(item)), onItemsUpdated: (items) => {
this.hasOfflineEntries = !!items.find(item => source.isOfflineEntry(item));
this.hasOffline = this.hasOfflineEntries || this.hasOfflineRatings;
},
}); });
// When an entry is added, we reload the data. // When an entry is added, we reload the data.
@ -146,12 +150,14 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
if (this.glossary && data.component == 'mod_glossary' && data.ratingArea == 'entry' && data.contextLevel == 'module' if (this.glossary && data.component == 'mod_glossary' && data.ratingArea == 'entry' && data.contextLevel == 'module'
&& data.instanceId == this.glossary.coursemodule) { && data.instanceId == this.glossary.coursemodule) {
this.hasOfflineRatings = true; this.hasOfflineRatings = true;
this.hasOffline = true;
} }
}); });
this.ratingSyncObserver = CoreEvents.on(CoreRatingSyncProvider.SYNCED_EVENT, (data) => { this.ratingSyncObserver = CoreEvents.on(CoreRatingSyncProvider.SYNCED_EVENT, (data) => {
if (this.glossary && data.component == 'mod_glossary' && data.ratingArea == 'entry' && data.contextLevel == 'module' if (this.glossary && data.component == 'mod_glossary' && data.ratingArea == 'entry' && data.contextLevel == 'module'
&& data.instanceId == this.glossary.coursemodule) { && data.instanceId == this.glossary.coursemodule) {
this.hasOfflineRatings = false; this.hasOfflineRatings = false;
this.hasOffline = this.hasOfflineEntries;
} }
}); });
} }
@ -198,6 +204,7 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
]); ]);
this.hasOfflineRatings = hasOfflineRatings; this.hasOfflineRatings = hasOfflineRatings;
this.hasOffline = this.hasOfflineEntries || this.hasOfflineRatings;
} }
/** /**

View File

@ -0,0 +1,233 @@
@mod @mod_glossary @app @javascript
Feature: Test basic usage of glossary in app
In order to participate in the glossaries while using the mobile app
As a student
I need basic glossary functionality to work
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | teacher | teacher1@example.com |
| teacher2 | Teacher2 | teacher2 | teacher2@example.com |
| student1 | Student | student | student1@example.com |
And the following "courses" exist:
| fullname | shortname | category |
| Course 1 | C1 | 0 |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
| teacher2 | C1 | editingteacher |
| student1 | C1 | student |
And the following "activities" exist:
| activity | name | intro | course | idnumber | mainglossary | allowcomments | assessed | scale |
| glossary | Test glossary | glossary description | C1 | gloss1 | 1 | 1 | 1 | 1 |
And the following "activities" exist:
| activity | name | intro | course | idnumber | groupmode |
| forum | Test forum name | Test forum | C1 | forum | 0 |
And the following "mod_glossary > categories" exist:
| glossary | name |
| gloss1 | The ones I like |
| gloss1 | All for you |
And the following "mod_glossary > entries" exist:
| glossary | concept | definition | user | categories | usedynalink |
| gloss1 | Eggplant | Sour eggplants | teacher1 | All for you | 0 |
| gloss1 | Cucumber | Sweet cucumber | student1 | The ones I like | 0 |
| gloss1 | Potato | To make chips | student1 | The ones I like | 1 |
| gloss1 | Raddish | Raphanus sativus | student1 | All for you | 1 |
Scenario: View a glossary and its terms
Given I entered the glossary activity "Test glossary" on course "Course 1" as "student1" in the app
Then the header should be "Test glossary" in the app
And I should find "Eggplant" in the app
And I should find "Cucumber" in the app
And I should find "Potato" in the app
When I press "Potato" in the app
Then I should find "Potato" in the app
And I should find "To make chips" in the app
Scenario: Navigate to glossary terms by link (auto-linking)
Given the "glossary" filter is "on"
And I entered the glossary activity "Test glossary" on course "Course 1" as "student1" in the app
Then the header should be "Test glossary" in the app
And I should find "Eggplant" in the app
And I should find "Cucumber" in the app
And I should find "Potato" in the app
And I should find "Raddish" in the app
When I press the back button in the app
And I press "Test forum name" in the app
And I press "Add discussion topic" in the app
And I set the field "Subject" to "Testing auto-link glossary"
And I set the field "Message" to "Glossary terms auto-linked: Raddish Potato" in the app
And I press "Post to forum" in the app
And I press "Testing auto-link glossary" in the app
Then I should find "Raddish" in the app
When I press "Raddish" in the app
Then the header should be "Raddish" in the app
And I should find "Raphanus sativus" in the app
When I press the back button in the app
And I press "Potato" in the app
Then the header should be "Potato" in the app
And I should find "To make chips" in the app
Scenario: See comments
Given I entered the glossary activity "Test glossary" on course "Course 1" as "student1" in the app
Then the header should be "Test glossary" in the app
When I press "Eggplant" in the app
Then I should find "Comments (0)" in the app
# Write comments as a teacher
Given I entered the glossary activity "Test glossary" on course "Course 1" as "teacher1" in the app
And I press "Eggplant" in the app
Then I should find "Comments (0)" in the app
When I press "Comments" in the app
Then I should find "No comments" in the app
And I set the field "Add a comment..." to "teacher first comment" in the app
And I press "Send" in the app
Then I should find "teacher first comment" in the app
And I set the field "Add a comment..." to "teacher second comment" in the app
And I press "Send" in the app
Then I should find "teacher first comment" in the app
And I should find "teacher second comment" in the app
# View comments as a student
Given I entered the glossary activity "Test glossary" on course "Course 1" as "student1" in the app
And I press "Eggplant" in the app
Then I should find "Comments (2)" in the app
When I press "Comments" in the app
And I should find "teacher first comment" in the app
And I should find "teacher second comment" in the app
Scenario: Prefetch
Given I entered the course "Course 1" as "student1" in the app
When I press "Course downloads" in the app
When I press "Download" within "Test glossary" "ion-item" in the app
And I press the back button in the app
And I switch offline mode to "true"
And I press "Test glossary" in the app
Then the header should be "Test glossary" in the app
And I should find "Cucumber" in the app
And I should find "Eggplant" in the app
And I should find "Potato" in the app
When I press "Eggplant" in the app
Then I should find "Eggplant" in the app
And I should find "Sour eggplants" in the app
And I should not see "Comments cannot be retrieved"
And I should find "Comments (0)" in the app
Scenario: Add entries (basic info)
Given I entered the glossary activity "Test glossary" on course "Course 1" as "student1" in the app
And I press "Add a new entry" in the app
And I set the following fields to these values in the app:
| Concept | Broccoli |
| Definition | Brassica oleracea var. italica |
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 | Cabbage |
| Definition | Brassica oleracea var. capitata |
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 | Garlic |
| Definition | Allium sativum |
And I press "Save" in the app
Then the header should be "Test glossary" in the app
And I should find "Cucumber" in the app
And I should find "Eggplant" in the app
And I should find "Potato" in the 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
When I press "Garlic" in the app
Then I should find "Garlic" in the app
And I should find "Allium sativum" 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
And I switch offline mode to "true"
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
And I press "Add a new entry" in the app
And I set the following fields to these values in the app:
| Concept | Cabbage |
| Definition | Brassica oleracea var. capitata |
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 | Garlic |
| Definition | Allium sativum |
And I press "Save" in the app
Then the header should be "Test glossary" in the app
And I should find "Cucumber" in the app
And I should find "Eggplant" in the app
And I should find "Potato" in the 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
And I should find "Entries to be synced" in the app
And I should find "This Glossary has offline data to be synchronised." in the app
When I switch offline mode to "false"
And I press "Information" in the app
And I press "Synchronise now" in the app
Then the header should be "Test glossary" in the app
And I should find "Cucumber" in the app
And I should find "Eggplant" in the app
And I should find "Potato" in the 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."
When I press "Garlic" in the app
Then I should find "Garlic" in the app
And I should find "Allium sativum" in the app
Scenario: Add/view ratings
# Rate entries as teacher1
Given I entered the glossary activity "Test glossary" on course "Course 1" as "teacher1" in the app
And I press "Cucumber" in the app
Then I should find "Average of ratings: -" in the app
When I press "None" in the app
And I press "1" in the app
Then I should find "Average of ratings: 1" in the app
# Rate entries as teacher2
Given I entered the glossary activity "Test glossary" on course "Course 1" as "teacher2" in the app
And I press "Cucumber" in the app
And I switch offline mode to "true"
And I press "None" in the app
And I press "0" in the app
Then I should find "Data stored in the device because it couldn't be sent. It will be sent automatically later." in the app
And I should find "Average of ratings: 1" in the app
When I switch offline mode to "false"
And I press the back button in the app
Then I should find "This Glossary has offline data to be synchronised." in the app
When I press "Information" in the app
And I press "Synchronise now" in the app
And I press "Cucumber" in the app
Then I should find "Average of ratings: 0.5" in the app
# View ratings as a student
Given I entered the glossary activity "Test glossary" on course "Course 1" as "student1" in the app
And I press "Cucumber" in the app
Then the header should be "Cucumber" in the app
But I should not see "Average of ratings: 0.5"

View File

@ -146,8 +146,12 @@ Feature: Test glossary navigation
When I press the back button in the app When I press the back button in the app
And I scroll to "Acerola" in the app And I scroll to "Acerola" in the app
And I press "Search" in the app And I press "Search" in the app
And I set the field "Search" to "melon" in the app And I set the field "Search" to "something" in the app
And I press "Search" "button" near "Clear search" in the app And I press enter
Then I should find "No entries were found." in the app
When I set the field "Search" to "melon" in the app
And I press enter
Then I should find "Honeydew Melon" in the app Then I should find "Honeydew Melon" in the app
And I should find "Watermelon" in the app And I should find "Watermelon" in the app
But I should not find "Acerola" in the app But I should not find "Acerola" in the app
@ -170,12 +174,14 @@ Feature: Test glossary navigation
And I press "Clear search" in the app And I press "Clear search" in the app
And I press "Add a new entry" in the app And I press "Add a new entry" in the app
And I switch offline mode to "true" And I switch offline mode to "true"
And I set the field "Concept" to "Tomato" in the app And I set the following fields to these values in the app:
And I set the field "Definition" to "Tomato is a fruit" in the app | Concept | Tomato |
| Definition | Tomato is a fruit |
And I press "Save" in the app And I press "Save" in the app
And I press "Add a new entry" in the app And I press "Add a new entry" in the app
And I set the field "Concept" to "Cashew" in the app And I set the following fields to these values in the app:
And I set the field "Definition" to "Cashew is a fruit" in the app | Concept | Cashew |
| Definition | Cashew is a fruit |
And I press "Save" in the app And I press "Save" in the app
Then I should find "Entries to be synced" in the app Then I should find "Entries to be synced" in the app
And I should find "Tomato" in the app And I should find "Tomato" in the app
@ -248,8 +254,12 @@ Feature: Test glossary navigation
# Search # Search
When I press "Search" in the app When I press "Search" in the app
And I set the field "Search" to "melon" in the app And I set the field "Search" to "something" in the app
And I press "Search" "button" near "Clear search" in the app And I press enter
Then I should find "No entries were found." in the app
When I set the field "Search" to "melon" in the app
And I press enter
Then I should find "Honeydew Melon" in the app Then I should find "Honeydew Melon" in the app
And I should find "Watermelon" in the app And I should find "Watermelon" in the app
And "Honeydew Melon" near "Watermelon" should be selected in the app And "Honeydew Melon" near "Watermelon" should be selected in the app
@ -265,11 +275,13 @@ Feature: Test glossary navigation
When I press "Clear search" in the app When I press "Clear search" in the app
And I press "Add a new entry" in the app And I press "Add a new entry" in the app
And I switch offline mode to "true" And I switch offline mode to "true"
And I set the field "Concept" to "Tomato" in the app And I set the following fields to these values in the app:
And I set the field "Definition" to "Tomato is a fruit" in the app | Concept | Tomato |
| Definition | Tomato is a fruit |
And I press "Save" in the app And I press "Save" in the app
And I set the field "Concept" to "Cashew" in the app And I set the following fields to these values in the app:
And I set the field "Definition" to "Cashew is a fruit" in the app | Concept | Cashew |
| Definition | Cashew is a fruit |
And I press "Save" in the app And I press "Save" in the app
Then I should find "Entries to be synced" in the app Then I should find "Entries to be synced" in the app
And I should find "Tomato" in the app And I should find "Tomato" in the app

View File

@ -27,7 +27,7 @@ import { CoreDomUtils } from '@services/utils/dom';
import { CoreMimetypeUtils } from '@services/utils/mimetype'; import { CoreMimetypeUtils } from '@services/utils/mimetype';
import { CoreTextUtils } from '@services/utils/text'; import { CoreTextUtils } from '@services/utils/text';
import { CoreUtils, OpenFileAction } from '@services/utils/utils'; import { CoreUtils, OpenFileAction } from '@services/utils/utils';
import { Network, NgZone, Translate } from '@singletons'; import { NgZone, Translate } from '@singletons';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { import {
AddonModResource, AddonModResource,
@ -83,7 +83,7 @@ export class AddonModResourceIndexComponent extends CoreCourseModuleMainResource
this.isOnline = CoreNetwork.isOnline(); this.isOnline = CoreNetwork.isOnline();
// Refresh online status when changes. // Refresh online status when changes.
this.onlineObserver = Network.onChange().subscribe(() => { this.onlineObserver = CoreNetwork.onChange().subscribe(() => {
// Execute the callback in the Angular zone, so change detection doesn't stop working. // Execute the callback in the Angular zone, so change detection doesn't stop working.
NgZone.run(() => { NgZone.run(() => {
this.isOnline = CoreNetwork.isOnline(); this.isOnline = CoreNetwork.isOnline();

View File

@ -50,14 +50,12 @@
<!-- Subquestion --> <!-- Subquestion -->
<ng-container *ngIf="question.parent !== 0"> <ng-container *ngIf="question.parent !== 0">
<ion-radio-group [(ngModel)]="answers[question.name]" [required]="question.required" [name]="question.name"> <ion-radio-group [(ngModel)]="answers[question.name]" [required]="question.required" [name]="question.name">
<ion-row *ngIf="question.parent !== 0" class="ion-align-items-center ion-padding-horizontal" [class.even]="isEven"> <ion-row class="ion-align-items-center ion-padding-horizontal" [class.even]="isEven">
<ion-col size="7"> <ion-col size="7">
<ion-label id="addon-mod_survey-{{question.id}}"> <span id="addon-mod_survey-{{question.name}}" [core-mark-required]="question.required">
<span [core-mark-required]="question.required">
<strong>{{question.num}}.</strong> {{ question.text }} <strong>{{question.num}}.</strong> {{ question.text }}
</span> </span>
</ion-label>
</ion-col> </ion-col>
<!-- Tablet view: radio buttons --> <!-- Tablet view: radio buttons -->
@ -69,8 +67,8 @@
</ion-col> </ion-col>
<ion-col class="ion-hide-md-up" size="5"> <ion-col class="ion-hide-md-up" size="5">
<ion-select class="ion-padding" [(ngModel)]="answers[question.name]" [required]="question.required" <ion-select class="ion-padding" [(ngModel)]="answers[question.name]" [required]="question.required"
[attr.aria-labelledby]="'addon-mod_survey-'+question.id" interface="action-sheet" [name]="question.name" [attr.aria-labelledby]="'addon-mod_survey-'+question.name" interface="action-sheet"
[interfaceOptions]="{header: question.text}"> [name]="question.name" [interfaceOptions]="{header: question.text}">
<ion-select-option value="-1" selected disabled>{{ 'core.choose' | translate }}</ion-select-option> <ion-select-option value="-1" selected disabled>{{ 'core.choose' | translate }}</ion-select-option>
<ion-select-option *ngFor="let option of question.optionsArray; let value=index;" [value]="value +1"> <ion-select-option *ngFor="let option of question.optionsArray; let value=index;" [value]="value +1">
{{option}} {{option}}
@ -85,17 +83,18 @@
<ng-container *ngIf="(!question.multiArray || question.multiArray.length == 0) && question.parent === 0"> <ng-container *ngIf="(!question.multiArray || question.multiArray.length == 0) && question.parent === 0">
<ion-row class="ion-align-items-center ion-padding" *ngIf="question.type > 0" [class.even]="isEven"> <ion-row class="ion-align-items-center ion-padding" *ngIf="question.type > 0" [class.even]="isEven">
<ion-col size="7"> <ion-col size="7">
<ion-label id="addon-mod_survey-{{question.id}}"> <span id="addon-mod_survey-{{question.name}}" [core-mark-required]="question.required">
<span [core-mark-required]="question.required">
<strong>{{question.num}}.</strong> {{ question.text }} <strong>{{question.num}}.</strong> {{ question.text }}
</span> </span>
</ion-label>
</ion-col> </ion-col>
<ion-col size="5"> <ion-col size="5">
<ion-select class="ion-padding" [(ngModel)]="answers[question.name]" [required]="question.required" <ion-select class="ion-padding" [(ngModel)]="answers[question.name]" [required]="question.required"
[attr.aria-labelledby]="'addon-mod_survey-'+question.id" interface="action-sheet" [name]="question.name" [attr.aria-labelledby]="'addon-mod_survey-'+question.name" interface="action-sheet" [name]="question.name"
[interfaceOptions]="{header: question.text}"> [interfaceOptions]="{header: question.text}">
<ion-select-option *ngFor="let option of question.optionsArray; let value=index;" [value]="value"> <ion-select-option [value]="question.required ? '-1' : '0'" selected [disabled]="question.required">
{{ 'core.choose' | translate }}
</ion-select-option>
<ion-select-option *ngFor="let option of question.optionsArray; let value=index;" [value]="value +1">
{{option}} {{option}}
</ion-select-option> </ion-select-option>
</ion-select> </ion-select>
@ -103,13 +102,13 @@
</ion-row> </ion-row>
<ion-item *ngIf="question.type === 0" class="ion-text-wrap" [class.even]="isEven"> <ion-item *ngIf="question.type === 0" class="ion-text-wrap" [class.even]="isEven">
<ion-label position="floating" id="addon-mod_survey-{{question.id}}"> <ion-label position="floating" id="addon-mod_survey-{{question.name}}">
<span [core-mark-required]="question.required"> <span [core-mark-required]="question.required">
<strong>{{question.num}}.</strong> {{ question.text }} <strong>{{question.num}}.</strong> {{ question.text }}
</span> </span>
</ion-label> </ion-label>
<ion-textarea [(ngModel)]="answers[question.name]" [name]="question.name" <ion-textarea [(ngModel)]="answers[question.name]" [name]="question.name"
[attr.aria-labelledby]="'addon-mod_survey-'+question.id" [required]="question.required"> [attr.aria-labelledby]="'addon-mod_survey-'+question.name" [required]="question.required">
</ion-textarea> </ion-textarea>
</ion-item> </ion-item>
</ng-container> </ng-container>

View File

@ -68,7 +68,6 @@ export class AddonModSurveyHelperProvider {
formatQuestions(questions: AddonModSurveyQuestion[]): AddonModSurveyQuestionFormatted[] { formatQuestions(questions: AddonModSurveyQuestion[]): AddonModSurveyQuestionFormatted[] {
const strIPreferThat = Translate.instant('addon.mod_survey.ipreferthat'); const strIPreferThat = Translate.instant('addon.mod_survey.ipreferthat');
const strIFoundThat = Translate.instant('addon.mod_survey.ifoundthat'); const strIFoundThat = Translate.instant('addon.mod_survey.ifoundthat');
const strChoose = Translate.instant('core.choose');
const formatted: AddonModSurveyQuestionFormatted[] = []; const formatted: AddonModSurveyQuestionFormatted[] = [];
const parents = this.getParentQuestions(questions); const parents = this.getParentQuestions(questions);
@ -112,9 +111,6 @@ export class AddonModSurveyHelperProvider {
// It's a single question. // It's a single question.
q1.name = 'q' + q1.id; q1.name = 'q' + q1.id;
q1.num = num++; q1.num = num++;
if (q1.type > 0) { // Add "choose" option since this question is not required.
q1.optionsArray.unshift(strChoose);
}
} }
formatted.push(q1); formatted.push(q1);

View File

@ -0,0 +1,272 @@
@mod @mod_survey @app @javascript
Feature: Test basic usage of survey activity in app
In order to participate in surveys while using the mobile app
As a student
I need basic survey functionality to work
Background:
Given the following "courses" exist:
| fullname | shortname |
| Course 1 | C1 |
And the following "users" exist:
| username |
| student1 |
| teacher1 |
And the following "course enrolments" exist:
| user | course | role |
| student1 | C1 | student |
| teacher1 | C1 | editingteacher |
And the following "activities" exist:
| activity | name | intro | course | idnumber | groupmode |
| survey | Test survey name | Test survey | C1 | survey | 0 |
Scenario: Answer a survey & View results (ATTLS)
Given I entered the survey activity "Test survey name" on course "Course 1" as "student1" in the app
And I set the following fields to these values in the app:
| 1. In evaluating what someone says, I focus on the quality of their argument, not on the person who's presenting it. | Strongly agree |
| 2. I like playing devil's advocate - arguing the opposite of what someone is saying. | Strongly disagree |
| 3. I like to understand where other people are 'coming from', what experiences have led them to feel the way they do. | Somewhat agree |
| 4. The most important part of my education has been learning to understand people who are very different to me. | Somewhat disagree |
| 5. I feel that the best way for me to achieve my own identity is to interact with a variety of other people. | Somewhat agree |
| 6. I enjoy hearing the opinions of people who come from backgrounds different to mine - it helps me to understand how the same things can be seen in such different ways. | Somewhat agree |
| 7. I find that I can strengthen my own position through arguing with someone who disagrees with me. | Somewhat agree |
| 8. I am always interested in knowing why people say and believe the things they do. | Somewhat agree |
| 9. I often find myself arguing with the authors of books that I read, trying to logically figure out why they're wrong. | Somewhat agree |
| 10. It's important for me to remain as objective as possible when I analyze something. | Somewhat agree |
| 11. I try to think with people instead of against them. | Somewhat agree |
| 12. I have certain criteria I use in evaluating arguments. | Somewhat agree |
| 13. I'm more likely to try to understand someone else's opinion than to try to evaluate it. | Somewhat agree |
| 14. I try to point out weaknesses in other people's thinking to help them clarify their arguments. | Somewhat agree |
| 15. I tend to put myself in other people's shoes when discussing controversial issues, to see why they think the way they do. | Somewhat agree |
| 16. One could call my way of analysing things 'putting them on trial' because I am careful to consider all the evidence. | Somewhat agree |
| 17. I value the use of logic and reason over the incorporation of my own concerns when solving problems. | Somewhat agree |
| 18. I can obtain insight into opinions that differ from mine through empathy. | Somewhat agree |
| 19. When I encounter people whose opinions seem alien to me, I make a deliberate effort to 'extend' myself into that person, to try to see how they could have those opinions. | Somewhat agree |
| 20. I spend time figuring out what's 'wrong' with things. For example, I'll look for something in a literary interpretation that isn't argued well enough. | Somewhat agree |
And I press "Submit" in the app
And I press "OK" in the app
And I press "Results" in the app
And I press "OK" in the app
And I switch to the browser tab opened by the app
And I log in as "student1"
Then I should see "You've completed this survey. The graph below shows a summary of your results compared to the class averages."
And I should see "1 people have completed this survey so far"
Scenario: Answer a survey & View results (Critical incidents)
Given the following "activities" exist:
| activity | name | intro | template |course | idnumber | groupmode |
| survey | Test survey critical incidents | Test survey1 | 5 | C1 | survey1 | 0 |
Given I entered the survey activity "Test survey critical incidents" on course "Course 1" as "student1" in the app
And I set the following fields to these values in the app:
| At what moment in class were you most engaged as a learner? | 1st answer |
| At what moment in class were you most distanced as a learner? | 2nd answer |
| What action from anyone in the forums did you find most affirming or helpful? | 3rd answer |
| What action from anyone in the forums did you find most puzzling or confusing? | 4th answer |
| What event surprised you most? | 5th answer |
And I press "Submit" in the app
And I press "OK" in the app
Then I should see "Results"
When I press "Results" in the app
And I press "OK" in the app
And I switch to the browser tab opened by the app
And I log in as "student1"
Then I should see "Test survey critical incidents"
And I should see "1st answer"
And I should see "2nd answer"
And I should see "3rd answer"
And I should see "4th answer"
And I should see "5th answer"
Scenario: Answer a survey & View results (Colles actual)
Given the following "activities" exist:
| activity | name | intro | template |course | idnumber | groupmode |
| survey | Test survey Colles (actual) | Test survey1 | 1 | C1 | survey1 | 0 |
Given I entered the survey activity "Test survey Colles (actual)" on course "Course 1" as "student1" in the app
And I set the following fields to these values in the app:
| 1. my learning focuses on issues that interest me. | Sometimes |
| 2. what I learn is important for my professional practice. | Sometimes |
| 3. I learn how to improve my professional practice. | Sometimes |
| 4. what I learn connects well with my professional practice. | Sometimes |
| 5. I think critically about how I learn. | Sometimes |
| 6. I think critically about my own ideas. | Sometimes |
| 7. I think critically about other students' ideas. | Sometimes |
| 8. I think critically about ideas in the readings. | Sometimes |
| 9. I explain my ideas to other students. | Sometimes |
| 10. I ask other students to explain their ideas. | Sometimes |
| 11. other students ask me to explain my ideas. | Sometimes |
| 12. other students respond to my ideas. | Sometimes |
| 13. the tutor stimulates my thinking. | Sometimes |
| 14. the tutor encourages me to participate. | Sometimes |
| 15. the tutor models good discourse. | Sometimes |
| 16. the tutor models critical self-reflection. | Sometimes |
| 17. other students encourage my participation. | Sometimes |
| 18. other students praise my contribution. | Sometimes |
| 19. other students value my contribution. | Sometimes |
| 20. other students empathise with my struggle to learn. | Sometimes |
| 21. I make good sense of other students' messages. | Sometimes |
| 22. other students make good sense of my messages. | Sometimes |
| 23. I make good sense of the tutor's messages. | Sometimes |
| 24. the tutor makes good sense of my messages. | Sometimes |
| 25. How long did this survey take you to complete? | under 1 min |
And I press "Submit" in the app
And I press "OK" in the app
Then I should see "You have completed this survey"
When I press "Results" in the app
And I press "OK" in the app
And I switch to the browser tab opened by the app
And I log in as "student1"
Then I should see "You've completed this survey. The graph below shows a summary of your results compared to the class averages."
And I should see "1 people have completed this survey so far"
Scenario: Answer a survey & View results (Colles preferred)
Given the following "activities" exist:
| activity | name | intro | template | course | idnumber | groupmode |
| survey | Test survey Colles (preferred) | Test survey1 | 2 | C1 | survey1 | 0 |
Given I entered the survey activity "Test survey Colles (preferred)" on course "Course 1" as "student1" in the app
And I set the following fields to these values in the app:
| 1. my learning focuses on issues that interest me. | Sometimes |
| 2. what I learn is important for my professional practice. | Sometimes |
| 3. I learn how to improve my professional practice. | Sometimes |
| 4. what I learn connects well with my professional practice. | Sometimes |
| 5. I think critically about how I learn. | Sometimes |
| 6. I think critically about my own ideas. | Sometimes |
| 7. I think critically about other students' ideas. | Sometimes |
| 8. I think critically about ideas in the readings. | Sometimes |
| 9. I explain my ideas to other students. | Sometimes |
| 10. I ask other students to explain their ideas. | Sometimes |
| 11. other students ask me to explain my ideas. | Sometimes |
| 12. other students respond to my ideas. | Sometimes |
| 13. the tutor stimulates my thinking. | Sometimes |
| 14. the tutor encourages me to participate. | Sometimes |
| 15. the tutor models good discourse. | Sometimes |
| 16. the tutor models critical self-reflection. | Sometimes |
| 17. other students encourage my participation. | Sometimes |
| 18. other students praise my contribution. | Sometimes |
| 19. other students value my contribution. | Sometimes |
| 20. other students empathise with my struggle to learn. | Sometimes |
| 21. I make good sense of other students' messages. | Sometimes |
| 22. other students make good sense of my messages. | Sometimes |
| 23. I make good sense of the tutor's messages. | Sometimes |
| 24. the tutor makes good sense of my messages. | Sometimes |
| 25. How long did this survey take you to complete? | under 1 min |
And I press "Submit" in the app
And I press "OK" in the app
Then I should see "You have completed this survey"
When I press "Results" in the app
And I press "OK" in the app
And I switch to the browser tab opened by the app
And I log in as "student1"
Then I should see "You've completed this survey. The graph below shows a summary of your results compared to the class averages."
And I should see "1 people have completed this survey so far"
Scenario: Answer a survey & View results (Colles preferred and actual)
Given the following "activities" exist:
| activity | name | intro | template | course | idnumber | groupmode |
| survey | Test survey Colles (preferred and actual) | Test survey1 | 3 | C1 | survey1 | 0 |
Given I entered the survey activity "Test survey Colles (preferred and actual)" on course "Course 1" as "student1" in the app
And I set the following fields to these values in the app:
| 1. I prefer that my learning focuses on issues that interest me. | Sometimes |
| 2. I found that my learning focuses on issues that interest me. | Sometimes |
| 3. I prefer that what I learn is important for my professional practice. | Sometimes |
| 4. I found that what I learn is important for my professional practice. | Sometimes |
| 5. I prefer that I learn how to improve my professional practice. | Sometimes |
| 6. I found that I learn how to improve my professional practice. | Sometimes |
| 7. I prefer that what I learn connects well with my professional practice. | Sometimes |
| 8. I found that what I learn connects well with my professional practice. | Sometimes |
| 9. I prefer that I think critically about how I learn. | Sometimes |
| 10. I found that I think critically about how I learn. | Sometimes |
| 11. I prefer that I think critically about my own ideas. | Sometimes |
| 12. I found that I think critically about my own ideas. | Sometimes |
| 13. I prefer that I think critically about other students' ideas. | Sometimes |
| 14. I found that I think critically about other students' ideas. | Sometimes |
| 15. I prefer that I think critically about ideas in the readings. | Sometimes |
| 16. I found that I think critically about ideas in the readings. | Sometimes |
| 17. I prefer that I explain my ideas to other students. | Sometimes |
| 18. I found that I explain my ideas to other students. | Sometimes |
| 19. I prefer that I ask other students to explain their ideas. | Sometimes |
| 20. I found that I ask other students to explain their ideas. | Sometimes |
| 21. I prefer that other students ask me to explain my ideas. | Sometimes |
| 22. I found that other students ask me to explain my ideas. | Sometimes |
| 23. I prefer that other students respond to my ideas. | Sometimes |
| 24. I found that other students respond to my ideas. | Sometimes |
| 25. I prefer that the tutor stimulates my thinking. | Sometimes |
| 26. I found that the tutor stimulates my thinking. | Sometimes |
| 27. I prefer that the tutor encourages me to participate. | Sometimes |
| 28. I found that the tutor encourages me to participate. | Sometimes |
| 29. I prefer that the tutor models good discourse. | Sometimes |
| 30. I found that the tutor models good discourse. | Sometimes |
| 31. I prefer that the tutor models critical self-reflection. | Sometimes |
| 32. I found that the tutor models critical self-reflection. | Sometimes |
| 33. I prefer that other students encourage my participation. | Sometimes |
| 34. I found that other students encourage my participation. | Sometimes |
| 35. I prefer that other students praise my contribution. | Sometimes |
| 36. I found that other students praise my contribution. | Sometimes |
| 37. I prefer that other students value my contribution. | Sometimes |
| 38. I found that other students value my contribution. | Sometimes |
| 39. I prefer that other students empathise with my struggle to learn. | Sometimes |
| 40. I found that other students empathise with my struggle to learn. | Sometimes |
| 41. I prefer that I make good sense of other students' messages. | Sometimes |
| 42. I found that I make good sense of other students' messages. | Sometimes |
| 43. I prefer that other students make good sense of my messages. | Sometimes |
| 44. I found that other students make good sense of my messages. | Sometimes |
| 45. I prefer that I make good sense of the tutor's messages. | Sometimes |
| 46. I found that I make good sense of the tutor's messages. | Sometimes |
| 47. I prefer that the tutor makes good sense of my messages. | Sometimes |
| 48. I found that the tutor makes good sense of my messages. | Sometimes |
| 49. How long did this survey take you to complete? | 1-2 min |
And I press "Submit" in the app
And I press "OK" in the app
Then I should see "You have completed this survey"
When I press "Results" in the app
And I press "OK" in the app
And I switch to the browser tab opened by the app
And I log in as "student1"
Then I should see "You've completed this survey. The graph below shows a summary of your results compared to the class averages."
And I should see "1 people have completed this survey so far"
Scenario: Answer survey offline & Sync survey
Given the following "activities" exist:
| activity | name | intro | template | course | idnumber | groupmode |
| survey | Test survey critical incidents | Test survey1 | 5 | C1 | survey1 | 0 |
Given I entered the survey activity "Test survey critical incidents" on course "Course 1" as "student1" in the app
And I switch offline mode to "true"
And I press "Submit" in the app
And I press "OK" in the app
Then I should see "This Survey has offline data to be synchronised."
When I switch offline mode to "false"
And I press the back button in the app
And I press "Test survey critical incidents" in the app
And I press "Information" in the app
And I press "Refresh" in the app
Then I should see "Results"
And I should see "You have completed this survey."
But I should not see "This Survey has offline data to be synchronised."
Scenario: Prefetch & Auto-sync survey
Given the following "activities" exist:
| activity | name | intro | template | course | idnumber | groupmode |
| survey | Test survey critical incidents | Test survey1 | 5 | C1 | survey1 | 0 |
Given I entered the course "Course 1" as "student1" in the app
And I press "Course downloads" in the app
And I press "Download" within "Test survey critical incidents" "ion-item" in the app
And I press the back button in the app
And I switch offline mode to "true"
And I press "Test survey name" in the app
Then I should see "There was a problem connecting to the site. Please check your connection and try again."
When I press "OK" in the app
And I press the back button in the app
And I press "Test survey critical incidents" in the app
And I press "Submit" in the app
And I press "OK" in the app
Then I should see "This Survey has offline data to be synchronised."
When I switch offline mode to "false"
And I run cron tasks in the app
Then I should not see "This Survey has offline data to be synchronised."
And I should see "You have completed this survey."

View File

@ -27,7 +27,7 @@ import { CoreNavigator } from '@services/navigator';
import { CoreSites } from '@services/sites'; import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
import { Network, Translate, NgZone } from '@singletons'; import { Translate, NgZone } from '@singletons';
import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { CoreText } from '@singletons/text'; import { CoreText } from '@singletons/text';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
@ -119,7 +119,7 @@ export class AddonModWikiIndexComponent extends CoreCourseModuleMainActivityComp
this.isOnline = CoreNetwork.isOnline(); this.isOnline = CoreNetwork.isOnline();
// Refresh online status when changes. // Refresh online status when changes.
this.onlineSubscription = Network.onChange().subscribe(() => { this.onlineSubscription = CoreNetwork.onChange().subscribe(() => {
// Execute the callback in the Angular zone, so change detection doesn't stop working. // Execute the callback in the Angular zone, so change detection doesn't stop working.
NgZone.run(() => { NgZone.run(() => {
this.isOnline = CoreNetwork.isOnline(); this.isOnline = CoreNetwork.isOnline();

View File

@ -19,7 +19,8 @@ import { BackButtonEvent, ScrollDetail } from '@ionic/core';
import { CoreLang } from '@services/lang'; import { CoreLang } from '@services/lang';
import { CoreLoginHelper } from '@features/login/services/login-helper'; import { CoreLoginHelper } from '@features/login/services/login-helper';
import { CoreEvents } from '@singletons/events'; import { CoreEvents } from '@singletons/events';
import { Network, NgZone, Platform, SplashScreen, Translate } from '@singletons'; import { NgZone, Platform, SplashScreen, Translate } from '@singletons';
import { CoreNetwork } from '@services/network';
import { CoreApp, CoreAppProvider } from '@services/app'; import { CoreApp, CoreAppProvider } from '@services/app';
import { CoreSites } from '@services/sites'; import { CoreSites } from '@services/sites';
import { CoreNavigator } from '@services/navigator'; import { CoreNavigator } from '@services/navigator';
@ -32,7 +33,6 @@ import { CoreConstants } from '@/core/constants';
import { CoreSitePlugins } from '@features/siteplugins/services/siteplugins'; import { CoreSitePlugins } from '@features/siteplugins/services/siteplugins';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreDomUtils } from '@services/utils/dom';
import { CoreDom } from '@singletons/dom'; import { CoreDom } from '@singletons/dom';
import { CoreNetwork } from '@services/network';
const MOODLE_VERSION_PREFIX = 'version-'; const MOODLE_VERSION_PREFIX = 'version-';
const MOODLEAPP_VERSION_PREFIX = 'moodleapp-'; const MOODLEAPP_VERSION_PREFIX = 'moodleapp-';
@ -308,7 +308,7 @@ export class AppComponent implements OnInit, AfterViewInit {
await Platform.ready(); await Platform.ready();
// Refresh online status when changes. // Refresh online status when changes.
Network.onChange().subscribe(() => { CoreNetwork.onChange().subscribe(() => {
// Execute the callback in the Angular zone, so change detection doesn't stop working. // Execute the callback in the Angular zone, so change detection doesn't stop working.
NgZone.run(() => { NgZone.run(() => {
const isOnline = CoreNetwork.isOnline(); const isOnline = CoreNetwork.isOnline();

View File

@ -36,7 +36,7 @@ export class CoreAnimations {
animate(300, keyframes([ animate(300, keyframes([
style({ opacity: 0, transform: 'translateX(-100%)', offset: 0 }), style({ opacity: 0, transform: 'translateX(-100%)', offset: 0 }),
style({ opacity: 1, transform: 'translateX(5%)', offset: 0.7 }), style({ opacity: 1, transform: 'translateX(5%)', offset: 0.7 }),
style({ opacity: 1, transform: 'translateX(0)', offset: 1.0 }), style({ opacity: 1, transform: 'translateX(0)', offset: 1 }),
])), ])),
]), ]),
// Leave animation. // Leave animation.
@ -44,7 +44,7 @@ export class CoreAnimations {
animate(300, keyframes([ animate(300, keyframes([
style({ opacity: 1, transform: 'translateX(0)', offset: 0 }), style({ opacity: 1, transform: 'translateX(0)', offset: 0 }),
style({ opacity: 1, transform: 'translateX(5%)', offset: 0.3 }), style({ opacity: 1, transform: 'translateX(5%)', offset: 0.3 }),
style({ opacity: 0, transform: 'translateX(-100%)', offset: 1.0 }), style({ opacity: 0, transform: 'translateX(-100%)', offset: 1 }),
])), ])),
]), ]),
// Enter animation. // Enter animation.
@ -52,7 +52,7 @@ export class CoreAnimations {
animate(300, keyframes([ animate(300, keyframes([
style({ opacity: 0, transform: 'translateX(100%)', offset: 0 }), style({ opacity: 0, transform: 'translateX(100%)', offset: 0 }),
style({ opacity: 1, transform: 'translateX(-5%)', offset: 0.7 }), style({ opacity: 1, transform: 'translateX(-5%)', offset: 0.7 }),
style({ opacity: 1, transform: 'translateX(0)', offset: 1.0 }), style({ opacity: 1, transform: 'translateX(0)', offset: 1 }),
])), ])),
]), ]),
// Leave animation. // Leave animation.
@ -60,7 +60,7 @@ export class CoreAnimations {
animate(300, keyframes([ animate(300, keyframes([
style({ opacity: 1, transform: 'translateX(0)', offset: 0 }), style({ opacity: 1, transform: 'translateX(0)', offset: 0 }),
style({ opacity: 1, transform: 'translateX(-5%)', offset: 0.3 }), style({ opacity: 1, transform: 'translateX(-5%)', offset: 0.3 }),
style({ opacity: 0, transform: 'translateX(100%)', offset: 1.0 }), style({ opacity: 0, transform: 'translateX(100%)', offset: 1 }),
])), ])),
]), ]),
]); ]);

View File

@ -61,6 +61,7 @@ import { CoreHorizontalScrollControlsComponent } from './horizontal-scroll-contr
import { CoreButtonWithSpinnerComponent } from './button-with-spinner/button-with-spinner'; import { CoreButtonWithSpinnerComponent } from './button-with-spinner/button-with-spinner';
import { CoreSwipeSlidesComponent } from './swipe-slides/swipe-slides'; import { CoreSwipeSlidesComponent } from './swipe-slides/swipe-slides';
import { CoreSwipeNavigationTourComponent } from './swipe-navigation-tour/swipe-navigation-tour'; import { CoreSwipeNavigationTourComponent } from './swipe-navigation-tour/swipe-navigation-tour';
import { CoreMessageComponent } from './message/message';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -84,6 +85,7 @@ import { CoreSwipeNavigationTourComponent } from './swipe-navigation-tour/swipe-
CoreLoadingComponent, CoreLoadingComponent,
CoreLocalFileComponent, CoreLocalFileComponent,
CoreMarkRequiredComponent, CoreMarkRequiredComponent,
CoreMessageComponent,
CoreModIconComponent, CoreModIconComponent,
CoreNavBarButtonsComponent, CoreNavBarButtonsComponent,
CoreNavigationBarComponent, CoreNavigationBarComponent,
@ -134,6 +136,7 @@ import { CoreSwipeNavigationTourComponent } from './swipe-navigation-tour/swipe-
CoreLoadingComponent, CoreLoadingComponent,
CoreLocalFileComponent, CoreLocalFileComponent,
CoreMarkRequiredComponent, CoreMarkRequiredComponent,
CoreMessageComponent,
CoreModIconComponent, CoreModIconComponent,
CoreNavBarButtonsComponent, CoreNavBarButtonsComponent,
CoreNavigationBarComponent, CoreNavigationBarComponent,

View File

@ -0,0 +1,45 @@
<div *ngIf="message" class="message-box" (longPress)="copyMessage()">
<div class="main">
<!-- User data. -->
<div class="message-user" *ngIf="message.showUserData">
<core-user-avatar slot="start" [user]="user" [courseId]="courseId" [linkProfile]="false" aria-hidden="true">
</core-user-avatar>
<div>{{ userFullname }}</div>
</div>
<div *ngIf="!message.showUserData" class="sr-only">
{{ isMine
? ('addon.messages.you' | translate)
: userFullname }}
</div>
<core-format-text class="message-text" [text]="text" (afterRender)="afterRender.emit()" [contextLevel]="contextLevel"
[contextInstanceId]="instanceId" [courseId]="courseId">
</core-format-text>
</div>
<div class="extra">
<div class="message-time">
<ng-container *ngIf="!message.pending && !message.deleted">
{{ time | coreFormatDate: 'strftimetime' }}
</ng-container>
<ng-container *ngIf="!message.pending && message.deleted">
<ion-icon name="fas-trash" aria-hidden="true"></ion-icon>
<span class="ion-text-wrap">
{{ 'core.deletedoffline' | translate }}
</span>
</ng-container>
<ion-icon *ngIf="message.pending" name="fas-clock" [attr.aria-label]="'core.notsent' | translate" role="status"></ion-icon>
</div>
<ion-button *ngIf="showDelete && !message.deleted && message.delete !== false" fill="clear" [@coreSlideInOut]="'fromRight'"
color="danger" (click)="delete($event)" [attr.aria-label]="'addon.messages.deletemessage' | translate" class="delete-button">
<ion-icon name="fas-trash" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
<ion-button *ngIf="showDelete && message.deleted" fill="clear" color="danger" (click)="undoDelete($event)"
[attr.aria-label]="'core.restore' | translate" class="delete-button">
<ion-icon name="fas-undo-alt" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
</div>
<div class="tail" *ngIf="message.showTail"></div>
</div>

View File

@ -0,0 +1,138 @@
@import "~theme/globals";
:host {
--message-background: var(--core-messages-message-bg);
--message-activated-background: var(--core-messages-message-activated-bg);
--message-alignment: flex-start;
display: flex;
justify-content: var(--message-alignment);
.message-box {
--background: var(--message-background);
--min-height: var(--a11y-min-target-size);
display: flex;
flex-direction: row;
position: relative;
border: 0;
border-radius: var(--medium-radius);
margin: 8px;
width: 90%;
max-width: var(--list-item-max-width);
min-height: 36px;
font-size: var(--text-size);
color: var(--ion-text-color);
background: var(--message-background);
@include core-transition(width);
// This is needed to display bubble tails.
overflow: visible;
&:hover {
-webkit-filter: drop-shadow(2px 2px 2px rgba(0,0,0,.3));
filter: drop-shadow(2px 2px 2px rgba(0,0,0,.3));
}
&[tappable]:active {
--message-background: var(--message-activated-background);
}
.main {
padding: 8px;
flex-grow: 1;
.message-user {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: .5rem;
margin-top: 0;
color: var(--ion-text-color);
core-user-avatar {
display: block;
--core-avatar-size: var(--core-messages-avatar-size);
margin: 0;
}
div {
font-weight: 500;
flex-grow: 1;
padding-left: .5rem;
padding-right: .5rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 16px;
}
}
.message-text {
::ng-deep > p:only-child {
display: inline;
margin: 0;
}
}
}
.extra {
flex-shrink: 1;
display: flex;
flex-direction: row;
padding-left: 8px;
padding-right: 8px;
.message-time {
padding-top: 8px;
color: var(--core-messages-message-note-text);
font-size: var(--core-messages-message-note-font-size);
}
.delete-button {
min-height: initial;
line-height: initial;
margin: 0px;
align-self: flex-end;
::ng-deep ion-icon {
font-size: 1.2em;
}
}
}
.tail {
content: '';
width: 0;
height: 0;
border: 0.5rem solid transparent;
position: absolute;
touch-action: none;
bottom: 0;
border-bottom-color: var(--message-background);
@include position(null, null, null, -8px);
}
}
&.no-user .message-box {
margin-top: 0px;
}
&.is-mine {
// Defined when a message is the user's.
--message-background: var(--core-messages-message-mine-bg);
--message-activated-background: var(--core-messages-message-mine-activated-bg);
--message-alignment: flex-end;
.message-box {
.tail {
@include position(null, -8px, null, unset);
}
}
}
}

View File

@ -0,0 +1,121 @@
// (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 { ContextLevel } from '@/core/constants';
import { Component, EventEmitter, HostBinding, Input, OnInit, Output } from '@angular/core';
import { CoreAnimations } from '@components/animations';
import { CoreSites } from '@services/sites';
import { CoreUtils } from '@services/utils/utils';
import { CoreTextUtils } from '@services/utils/text';
import { CoreUserWithAvatar } from '@components/user-avatar/user-avatar';
/**
* Component to handle a message in a conversation.
*/
@Component({
selector: 'core-message',
templateUrl: 'message.html',
styleUrls: ['message.scss'],
animations: [CoreAnimations.SLIDE_IN_OUT],
})
export class CoreMessageComponent implements OnInit {
@Input() message?: CoreMessageData; // The message object.
@Input() user?: CoreUserWithAvatar; // The user object.
@Input() text = ''; // Message text.
@Input() time = 0; // Message time.
@Input() instanceId = 0;
@Input() courseId?: number;
@Input() contextLevel: ContextLevel = ContextLevel.SYSTEM;
@Input() showDelete = false;
@Output() onDeleteMessage = new EventEmitter<void>();
@Output() onUndoDeleteMessage = new EventEmitter<void>();
@Output() afterRender = new EventEmitter<void>();
protected deleted = false; // Needed to fix animation to void in Behat tests.
// @TODO Recover the animation using native css or wait for Angular 13.1
// where the bug https://github.com/angular/angular/issues/30693 is solved.
// @HostBinding('@coreSlideInOut') get animation(): string {
// return this.isMine ? '' : 'fromLeft';
// }
@HostBinding('class.is-mine') isMine = false;
@HostBinding('class.no-user') get showUser(): boolean {
return !this.message?.showUserData;
};
get userId(): number | undefined {
return this.user && (this.user.userid || this.user.id);
}
get userFullname(): string | undefined {
return this.user && (this.user.fullname || this.user.userfullname);
}
/**
* @inheritdoc
*/
async ngOnInit(): Promise<void> {
const currentUserId = CoreSites.getCurrentSiteUserId();
this.isMine = this.userId === currentUserId;
}
/**
* Emits the delete action.
*
* @param event Event.
*/
delete(event: Event): void {
event.preventDefault();
event.stopPropagation();
this.onDeleteMessage.emit();
}
/**
* Emits the undo delete action.
*
* @param event Event.
*/
undoDelete(event: Event): void {
event.preventDefault();
event.stopPropagation();
this.onUndoDeleteMessage.emit();
}
/**
* Copy message to clipboard.
*/
copyMessage(): void {
CoreUtils.copyToClipboard(CoreTextUtils.decodeHTMLEntities(this.text));
}
}
/**
* Conversation message with some calculated data.
*/
type CoreMessageData = {
pending?: boolean; // Whether the message is pending to be sent.
sending?: boolean; // Whether the message is being sent right now.
showDate?: boolean; // Whether to show the date before the message.
deleted?: boolean; // Whether the message has been deleted.
showUserData?: boolean; // Whether to show the user data in the message.
showTail?: boolean; // Whether to show a "tail" in the message.
delete?: boolean; // Permission to delete=true/false.
};

View File

@ -156,7 +156,7 @@ export class CoreUserAvatarComponent implements OnInit, OnChanges, OnDestroy {
/** /**
* Type with all possible formats of user. * Type with all possible formats of user.
*/ */
type CoreUserWithAvatar = CoreUserBasicData & { export type CoreUserWithAvatar = CoreUserBasicData & {
userpictureurl?: string; userpictureurl?: string;
userprofileimageurl?: string; userprofileimageurl?: string;
profileimageurlsmall?: string; profileimageurlsmall?: string;

View File

@ -45,71 +45,20 @@
{{ comment.timecreated * 1000 | coreFormatDate: "strftimedayshort" }} {{ comment.timecreated * 1000 | coreFormatDate: "strftimedayshort" }}
</p> </p>
<ion-item class="ion-text-wrap addon-message" [class.addon-message-mine]="comment.userid == currentUserId" <core-message [message]="comment" [text]="comment.content" [time]="comment.timecreated * 1000" [user]="comment"
[class.addon-message-not-mine]="comment.userid != currentUserId" [class.addon-message-no-user]="!comment.showUserData" [showDelete]="showDelete" [contextLevel]="contextLevel" [instanceId]="instanceId" [courseId]="courseId"
[@coreSlideInOut]="comment.userid == currentUserId ? '' : 'fromLeft'"> (onDeleteMessage)="deleteComment(comment)" (onUndoDeleteMessage)="undoDeleteComment(comment)">
<ion-label> </core-message>
<!-- User data. -->
<h2 class="addon-message-user" *ngIf="comment.showUserData">
<core-user-avatar slot="start" [user]="comment" [linkProfile]="false">
</core-user-avatar>
<div>{{ comment.fullname }}</div>
</h2>
<div class="addon-message-text">
<core-format-text [text]="comment.content" [contextLevel]="contextLevel" [contextInstanceId]="instanceId"
[courseId]="courseId">
</core-format-text>
</div>
</ion-label>
<ion-note slot="end">
<ng-container *ngIf="!comment.deleted">
{{ comment.timecreated * 1000 | coreFormatDate: 'strftimetime' }}
</ng-container>
<ng-container *ngIf="comment.deleted">
<ion-icon name="fas-trash" aria-hidden="true"></ion-icon> <span class="ion-text-wrap">
{{ 'core.deletedoffline' | translate }}
</span>
</ng-container>
</ion-note>
<div class="tail" *ngIf="comment.showTail"></div>
<ion-button *ngIf="showDelete && !comment.deleted && comment.delete" slot="end" fill="clear"
[@coreSlideInOut]="'fromRight'" color="danger" (click)="deleteComment($event, comment)"
[attr.aria-label]="'core.delete' | translate" class="addon-messages-delete-button">
<ion-icon name="fas-trash" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
<ion-button *ngIf="showDelete && comment.deleted" slot="end" fill="clear" color="danger"
(click)="undoDeleteComment($event, comment)" [attr.aria-label]="'core.restore' | translate"
class="addon-messages-delete-button">
<ion-icon name="fas-undo-alt" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
</ion-item>
</ng-container> </ng-container>
<ion-item *ngIf="offlineComment" class="ion-text-wrap addon-message addon-message-mine"> <ion-badge class="ion-text-wrap" color="info" *ngIf="hasOffline">
<ion-label>
<!-- User data. -->
<p class="ion-text-center">
<ion-icon name="fas-exclamation-triangle" aria-hidden="true"></ion-icon> <ion-icon name="fas-exclamation-triangle" aria-hidden="true"></ion-icon>
{{ 'core.thereisdatatosync' | translate:{$a: 'core.comments.comments' | translate | lowercase } }} {{ 'core.thereisdatatosync' | translate:{$a: 'core.comments.comments' | translate | lowercase } }}
</p> </ion-badge>
<core-message *ngIf="hasOffline && offlineComment" [message]="offlineComment" [text]="offlineComment.content"
<div class="addon-message-text"> [user]="offlineComment" [showDelete]="showDelete" [contextLevel]="contextLevel" [instanceId]="instanceId"
<core-format-text [text]="offlineComment.content" [contextLevel]="contextLevel" [contextInstanceId]="instanceId" [courseId]="courseId" (onDeleteMessage)="deleteComment(offlineComment)">
[courseId]="courseId"> </core-message>
</core-format-text>
</div>
</ion-label>
<ion-note>
<ion-icon name="fas-clock" aria-hidden="true"></ion-icon> {{ 'core.notsent' | translate }}
</ion-note>
<div class="tail"></div>
<ion-button *ngIf="showDelete" slot="end" fill="clear" [@coreSlideInOut]="'fromRight'" color="danger"
(click)="deleteComment($event, offlineComment)" [attr.aria-label]="'core.delete' | translate"
class="addon-messages-delete-button">
<ion-icon name="fas-trash" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
</ion-item>
</ion-list> </ion-list>
</core-loading> </core-loading>

View File

@ -14,7 +14,6 @@
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { CoreAnimations } from '@components/animations';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { CoreSites } from '@services/sites'; import { CoreSites } from '@services/sites';
import { import {
@ -30,7 +29,7 @@ import {
import { IonContent, IonRefresher } from '@ionic/angular'; import { IonContent, IonRefresher } from '@ionic/angular';
import { ContextLevel, CoreConstants } from '@/core/constants'; import { ContextLevel, CoreConstants } from '@/core/constants';
import { CoreNavigator } from '@services/navigator'; import { CoreNavigator } from '@services/navigator';
import { Network, NgZone, Translate } from '@singletons'; import { NgZone, Translate } from '@singletons';
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreDomUtils } from '@services/utils/dom';
import { CoreUser } from '@features/user/services/user'; import { CoreUser } from '@features/user/services/user';
@ -43,6 +42,7 @@ import { CoreApp } from '@services/app';
import { CoreNetwork } from '@services/network'; import { CoreNetwork } from '@services/network';
import moment from 'moment'; import moment from 'moment';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { CoreAnimations } from '@components/animations';
/** /**
* Page that displays comments. * Page that displays comments.
@ -75,7 +75,7 @@ export class CoreCommentsViewerPage implements OnInit, OnDestroy {
hasOffline = false; hasOffline = false;
refreshIcon = CoreConstants.ICON_LOADING; refreshIcon = CoreConstants.ICON_LOADING;
syncIcon = CoreConstants.ICON_LOADING; syncIcon = CoreConstants.ICON_LOADING;
offlineComment?: CoreCommentsOfflineWithUser; offlineComment?: CoreCommentsOfflineWithUser & { pending?: boolean };
currentUserId: number; currentUserId: number;
sending = false; sending = false;
newComment = ''; newComment = '';
@ -110,7 +110,7 @@ export class CoreCommentsViewerPage implements OnInit, OnDestroy {
}, CoreSites.getCurrentSiteId()); }, CoreSites.getCurrentSiteId());
this.isOnline = CoreNetwork.isOnline(); this.isOnline = CoreNetwork.isOnline();
this.onlineObserver = Network.onChange().subscribe(() => { this.onlineObserver = CoreNetwork.onChange().subscribe(() => {
// Execute the callback in the Angular zone, so change detection doesn't stop working. // Execute the callback in the Angular zone, so change detection doesn't stop working.
NgZone.run(() => { NgZone.run(() => {
this.isOnline = CoreNetwork.isOnline(); this.isOnline = CoreNetwork.isOnline();
@ -222,13 +222,15 @@ export class CoreCommentsViewerPage implements OnInit, OnDestroy {
* @param infiniteComplete Infinite scroll complete function. Only used from core-infinite-loading. * @param infiniteComplete Infinite scroll complete function. Only used from core-infinite-loading.
* @return Resolved when done. * @return Resolved when done.
*/ */
loadPrevious(infiniteComplete?: () => void): Promise<void> { async loadPrevious(infiniteComplete?: () => void): Promise<void> {
this.page++; this.page++;
this.canLoadMore = false; this.canLoadMore = false;
return this.fetchComments(true).finally(() => { try {
await this.fetchComments(true);
} finally {
infiniteComplete && infiniteComplete(); infiniteComplete && infiniteComplete();
}); }
} }
/** /**
@ -359,13 +361,9 @@ export class CoreCommentsViewerPage implements OnInit, OnDestroy {
/** /**
* Delete a comment. * Delete a comment.
* *
* @param e Click event.
* @param comment Comment to delete. * @param comment Comment to delete.
*/ */
async deleteComment(e: Event, comment: CoreCommentsDataToDisplay | CoreCommentsOfflineWithUser): Promise<void> { async deleteComment(comment: CoreCommentsDataToDisplay | CoreCommentsOfflineWithUser): Promise<void> {
e.preventDefault();
e.stopPropagation();
const modified = 'lastmodified' in comment const modified = 'lastmodified' in comment
? comment.lastmodified ? comment.lastmodified
: comment.timecreated; : comment.timecreated;
@ -527,15 +525,16 @@ export class CoreCommentsViewerPage implements OnInit, OnDestroy {
).then(async (offlineComment) => { ).then(async (offlineComment) => {
this.offlineComment = offlineComment; this.offlineComment = offlineComment;
if (!offlineComment) { if (!this.offlineComment) {
return; return;
} }
if (this.newComment == '') { if (this.newComment == '') {
this.newComment = this.offlineComment!.content; this.newComment = this.offlineComment.content;
} }
this.offlineComment!.userid = this.currentUserId; this.offlineComment.userid = this.currentUserId;
this.offlineComment.pending = true;
return; return;
})); }));
@ -571,13 +570,9 @@ export class CoreCommentsViewerPage implements OnInit, OnDestroy {
/** /**
* Restore a comment. * Restore a comment.
* *
* @param e Click event.
* @param comment Comment to delete. * @param comment Comment to delete.
*/ */
async undoDeleteComment(e: Event, comment: CoreCommentsDataToDisplay): Promise<void> { async undoDeleteComment(comment: CoreCommentsDataToDisplay): Promise<void> {
e.preventDefault();
e.stopPropagation();
await CoreCommentsOffline.undoDeleteComment(comment.id); await CoreCommentsOffline.undoDeleteComment(comment.id);
comment.deleted = false; comment.deleted = false;

View File

@ -1 +1,5 @@
@import "~theme/components/discussion.scss"; @import "~theme/components/discussion.scss";
ion-badge {
margin: 8px auto;
}

View File

@ -0,0 +1,327 @@
@core @core_comments @app @javascript
Feature: Test basic usage of comments in app
In order to participate in the comments while using the mobile app
As a student
I need basic comments functionality to work
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | teacher | teacher1@example.com |
| student1 | Student | student | student1@example.com |
And the following "courses" exist:
| fullname | shortname | category |
| Course 1 | C1 | 0 |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
| student1 | C1 | student |
And the following "activities" exist:
| activity | name | intro | course | idnumber | mainglossary | allowcomments | assessed | scale |
| glossary | Test glossary | glossary description | C1 | gloss1 | 1 | 1 | 1 | 1 |
And the following "activities" exist:
| activity | name | intro | course | idnumber | comments |
| data | Data | Data info | C1 | data1 | 1 |
Scenario: Add comments & Delete comments (database)
# Create database entry and comment as a teacher
Given I entered the data activity "Data" on course "Course 1" as "teacher1" in the app
And I press "Information" in the app
# TODO Create and use a generator for database fields.
And I press "Open in browser" in the app
And I switch to the browser tab opened by the app
And I log in as "teacher1"
And I add a "Text input" field to "Data" database and I fill the form with:
| Field name | Test field name |
| Field description | Test field description |
And I press "Save"
And I close the browser tab opened by the app
When I entered the course "Course 1" as "teacher1" in the app
And I press "Data" in the app
And I press "Add entries" in the app
And I set the field "Test field name" to "Test" in the app
And I press "Save" in the app
And I press "More" in the app
And I press "Comments (0)" in the app
And I set the field "Add a comment..." to "comment test teacher" in the app
And I press "Send" in the app
Then I should find "Comment created" in the app
And I should find "comment test teacher" in the app
When I press the back button in the app
And I should find "Comments (1)" in the app
# Create and delete comments as a student
Given I entered the data activity "Data" on course "Course 1" as "student1" in the app
And I press "More" in the app
And I press "Comments (1)" in the app
And I set the field "Add a comment..." to "comment test student" in the app
And I press "Send" in the app
Then I should find "Comment created" in the app
And I should find "comment test teacher" in the app
And I should find "comment test student" in the app
When I press the back button in the app
And I press "Comments (2)" in the app
And I press "Toggle delete buttons" in the app
And I press "Delete" near "comment test student" in the app
And I press "Delete" near "Cancel" in the app
Then I should find "Comment deleted" in the app
And I should find "comment test teacher" in the app
But I should not see "comment test student"
When I press the back button in the app
Then I should find "Comments (1)" in the app
Scenario: Add comments offline & Delete comments offline & Sync comments (database)
Given I entered the data activity "Data" on course "Course 1" as "teacher1" in the app
When I press "Information" in the app
And I press "Open in browser" in the app
And I switch to the browser tab opened by the app
And I log in as "teacher1"
And I add a "Text input" field to "Data" database and I fill the form with:
| Field name | Test field name |
| Field description | Test field description |
And I press "Save"
And I close the browser tab opened by the app
Given I entered the data activity "Data" on course "Course 1" as "teacher1" in the app
Then I press "Add entries" in the app
And I set the field "Test field name" to "Test" in the app
And I press "Save" in the app
And I press "More" in the app
And I press "Comments (0)" in the app
And I switch offline mode to "true"
And I set the field "Add a comment..." to "comment test" in the app
And I press "Send" in the app
Then I should find "Data stored in the device because it couldn't be sent. It will be sent automatically later." in the app
And I should find "There are offline comments to be synchronised." in the app
And I should find "comment test" in the app
When I press the back button in the app
And I press "Comments (0)" in the app
And I switch offline mode to "false"
And I press "Display options" in the app
And I press "Synchronise now" in the app
And I close the popup in the app
Then I should find "comment test" in the app
But I should not see "There are offline comments to be synchronised."
When I press the back button in the app
And I press "Comments (1)" in the app
And I switch offline mode to "true"
And I press "Toggle delete buttons" in the app
And I press "Delete" in the app
And I press "Delete" near "Cancel" in the app
Then I should find "Comment deleted" in the app
And I should find "There are offline comments to be synchronised." in the app
And I should find "Deleted offline" in the app
And I should find "comment test" in the app
When I press the back button in the app
And I press "Comments (1)" in the app
And I switch offline mode to "false"
And I press "Display options" in the app
And I press "Synchronise now" in the app
And I close the popup in the app
Then I should not see "There are offline comments to be synchronised."
And I should not see "comment test"
When I press the back button in the app
And I should find "Comments (0)" in the app
Scenario: Add comments & delete comments (glossary)
# Create glossary entry and comment as a teacher
Given I entered the glossary activity "Test glossary" on course "Course 1" as "teacher1" 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 | potato |
| Definition | The potato is a root vegetable native to the Americas, a starchy tuber of the plant Solanum tuberosum, and the plant itself, a perennial in the family Solanaceae. |
And I press "Save" in the app
And I press "potato" in the app
And I press "Comments (0)" in the app
And I set the field "Add a comment..." to "comment test teacher" in the app
And I press "Send" in the app
Then I should find "Comment created" in the app
And I should find "comment test teacher" in the app
And I press the back button in the app
And I should find "Comments (1)" in the app
# Create and delete comments as a student
When I entered the course "Course 1" as "student1" in the app
And I press "Test glossary" in the app
And I press "potato" in the app
And I press "Comments (1)" in the app
And I set the field "Add a comment..." to "comment test student" in the app
And I press "Send" in the app
Then I should find "Comment created" in the app
And I should find "comment test teacher" in the app
And I should find "comment test student" in the app
When I press the back button in the app
And I press "Comments (2)" in the app
And I press "Toggle delete buttons" in the app
And I press "Delete" near "comment test student" in the app
And I press "Delete" near "Cancel" in the app
Then I should find "Comment deleted" in the app
And I should find "comment test teacher" in the app
But I should not see "comment test student"
When I press the back button in the app
And I should find "Comments (1)" in the app
Scenario: Add comments offline & Delete comments offline & Sync comments (glossary)
Given I entered the glossary activity "Test glossary" on course "Course 1" as "teacher1" 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 | potato |
| Definition | The potato is a root vegetable native to the Americas, a starchy tuber of the plant Solanum tuberosum, and the plant itself, a perennial in the family Solanaceae. |
And I press "Save" in the app
And I press "potato" in the app
And I press "Comments (0)" in the app
And I switch offline mode to "true"
And I set the field "Add a comment..." to "comment test" in the app
And I press "Send" in the app
Then I should find "Data stored in the device because it couldn't be sent. It will be sent automatically later." in the app
And I should find "There are offline comments to be synchronised." in the app
And I should find "comment test" in the app
When I press the back button in the app
And I press "Comments (0)" in the app
And I switch offline mode to "false"
And I press "Display options" in the app
And I press "Synchronise now" in the app
And I close the popup in the app
Then I should find "comment test" in the app
But I should not see "There are offline comments to be synchronised."
When I press the back button in the app
And I press "Comments (1)" in the app
And I switch offline mode to "true"
And I press "Toggle delete buttons" in the app
And I press "Delete" in the app
And I press "Delete" near "Cancel" in the app
Then I should find "Comment deleted" in the app
And I should find "There are offline comments to be synchronised." in the app
And I should find "Deleted offline" in the app
And I should find "comment test" in the app
When I press the back button in the app
And I press "Comments (1)" in the app
And I switch offline mode to "false"
And I press "Display options" in the app
And I press "Synchronise now" in the app
And I close the popup in the app
Then I should not see "There are offline comments to be synchronised."
And I should not see "comment test"
When I press the back button in the app
And I should find "Comments (0)" in the app
Scenario: Add comments & Delete comments (blogs)
# Create blog as a teacher
Given the following "blocks" exist:
| blockname | contextlevel | reference | pagetypepattern | defaultregion | configdata |
| blog_menu | Course | C1 | course-view-* | site-pre | |
And I entered the course "Course 1" as "teacher1" in the app
And I press "Course summary" in the app
# TODO Create and use a generator blog entries.
And I press "Open in browser" in the app
And I switch to the browser tab opened by the app
And I log in as "teacher1"
And I click on "Open block drawer" "button"
And I click on "Add an entry about this course" "link" in the "Blog menu" "block"
And I set the following fields to these values:
| Entry title | Blog test |
| Blog entry body | Blog body |
And I press "Save changes"
And I close the browser tab opened by the app
# Create and delete comments as a student
When I entered the app as "student1"
And I press the more menu button in the app
And I press "Site blog" in the app
Then I should find "Blog test" in the app
And I should find "Blog body" in the app
When I press "Comments (0)" in the app
And I set the field "Add a comment..." to "comment test" in the app
And I press "Send" in the app
Then I should find "Comment created" in the app
And I should find "comment test" in the app
When I press the back button in the app
And I press "Comments (1)" in the app
And I press "Toggle delete buttons" in the app
And I press "Delete" in the app
And I press "Delete" near "Cancel" in the app
Then I should find "Comment deleted" in the app
But I should not see "comment test"
When I press the back button in the app
Then I should find "Comments (0)" in the app
Scenario: Add comments offline & Delete comments offline & Sync comments (blogs)
# Create blog as a teacher
Given the following "blocks" exist:
| blockname | contextlevel | reference | pagetypepattern | defaultregion | configdata |
| blog_menu | Course | C1 | course-view-* | site-pre | |
And I entered the course "Course 1" as "teacher1" in the app
And I press "Course summary" in the app
And I press "Open in browser" in the app
And I switch to the browser tab opened by the app
And I log in as "teacher1"
And I click on "Open block drawer" "button"
And I click on "Add an entry about this course" "link" in the "Blog menu" "block"
And I set the following fields to these values:
| Entry title | Blog test |
| Blog entry body | Blog body |
And I press "Save changes"
And I close the browser tab opened by the app
# Create and delete comments as a student
When I entered the app as "student1"
And I press the more menu button in the app
And I press "Site blog" in the app
Then I should find "Blog test" in the app
And I should find "Blog body" in the app
When I press "Comments (0)" in the app
And I switch offline mode to "true"
And I set the field "Add a comment..." to "comment test" in the app
And I press "Send" in the app
Then I should find "Data stored in the device because it couldn't be sent. It will be sent automatically later." in the app
And I should find "There are offline comments to be synchronised." in the app
And I should find "comment test" in the app
When I press the back button in the app
And I press "Comments (0)" in the app
And I switch offline mode to "false"
And I press "Display options" in the app
And I press "Synchronise now" in the app
And I close the popup in the app
Then I should find "comment test" in the app
But I should not see "There are offline comments to be synchronised."
When I press the back button in the app
And I press "Comments (1)" in the app
And I switch offline mode to "true"
And I press "Toggle delete buttons" in the app
And I press "Delete" in the app
And I press "Delete" near "Cancel" in the app
Then I should find "Comment deleted" in the app
And I should find "There are offline comments to be synchronised." in the app
And I should find "Deleted offline" in the app
And I should find "comment test" in the app
When I press the back button in the app
And I press "Comments (1)" in the app
And I switch offline mode to "false"
And I press "Display options" in the app
And I press "Synchronise now" in the app
And I close the popup in the app
Then I should not see "There are offline comments to be synchronised."
And I should not see "comment test"
When I press the back button in the app
Then I should find "Comments (0)" in the app

View File

@ -30,7 +30,7 @@ import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreDomUtils } from '@services/utils/dom';
import { CoreTextUtils } from '@services/utils/text'; import { CoreTextUtils } from '@services/utils/text';
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
import { ModalController, Network, NgZone } from '@singletons'; import { ModalController, NgZone } from '@singletons';
import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
@ -82,7 +82,7 @@ export class CoreCourseModuleSummaryComponent implements OnInit, OnDestroy {
this.isOnline = CoreNetwork.isOnline(); this.isOnline = CoreNetwork.isOnline();
// Refresh online status when changes. // Refresh online status when changes.
this.onlineSubscription = Network.onChange().subscribe(() => { this.onlineSubscription = CoreNetwork.onChange().subscribe(() => {
// Execute the callback in the Angular zone, so change detection doesn't stop working. // Execute the callback in the Angular zone, so change detection doesn't stop working.
NgZone.run(() => { NgZone.run(() => {
this.isOnline = CoreNetwork.isOnline(); this.isOnline = CoreNetwork.isOnline();

View File

@ -10,20 +10,23 @@ Feature: Test basic usage of courses in app
| teacher1 | Teacher | teacher | teacher1@example.com | | teacher1 | Teacher | teacher | teacher1@example.com |
| student1 | Student | student | student1@example.com | | student1 | Student | student | student1@example.com |
And the following "courses" exist: And the following "courses" exist:
| fullname | shortname | category | | fullname | shortname | category | visible |
| Course 1 | C1 | 0 | | Course 1 | C1 | 0 | 1 |
| Course 2 | C2 | 0 | | Course 2 | C2 | 0 | 1 |
| Course 3 | C3 | 0 | | Course 3 | C3 | 0 | 1 |
| Course 4 | C4 | 0 | | Course 4 | C4 | 0 | 1 |
| Hidden course | CH | 0 | 0 |
And the following "course enrolments" exist: And the following "course enrolments" exist:
| user | course | role | | user | course | role |
| teacher1 | C1 | editingteacher | | teacher1 | C1 | editingteacher |
| teacher1 | C2 | editingteacher | | teacher1 | C2 | editingteacher |
| teacher1 | C3 | editingteacher | | teacher1 | C3 | editingteacher |
| teacher1 | C4 | editingteacher | | teacher1 | C4 | editingteacher |
| teacher1 | CH | editingteacher |
| student1 | C1 | student | | student1 | C1 | student |
| student1 | C2 | student | | student1 | C2 | student |
| student1 | C3 | student | | student1 | C3 | student |
| student1 | CH | student |
And the following "activities" exist: And the following "activities" exist:
| activity | name | intro | course | idnumber | option | | activity | name | intro | course | idnumber | option |
| choice | Choice course 1 | Test choice description | C1 | choice1 | Option 1, Option 2, Option 3 | | choice | Choice course 1 | Test choice description | C1 | choice1 | Option 1, Option 2, Option 3 |
@ -49,6 +52,18 @@ Feature: Test basic usage of courses in app
And I should find "Course 2" in the app And I should find "Course 2" in the app
And I should find "Course 3" in the app And I should find "Course 3" in the app
@lms_from4.0
Scenario: Hidden course is only accessible for teachers
Given I entered the app as "teacher1"
And I press "My courses" in the app
When I press "Hidden course" in the app
Then the header should be "Hidden course" in the app
Given I entered the app as "student1"
And I press "My courses" in the app
And I should not find "Hidden course" in the app
@lms_from4.0 @lms_from4.0
Scenario: See my courses Scenario: See my courses
Given I entered the app as "student1" Given I entered the app as "student1"

View File

@ -81,8 +81,8 @@ import { FileTransferMock } from './services/file-transfer';
import { GeolocationMock } from './services/geolocation'; import { GeolocationMock } from './services/geolocation';
import { InAppBrowserMock } from './services/inappbrowser'; import { InAppBrowserMock } from './services/inappbrowser';
import { MediaCaptureMock } from './services/media-capture'; import { MediaCaptureMock } from './services/media-capture';
import { NetworkMock } from './services/network';
import { ZipMock } from './services/zip'; import { ZipMock } from './services/zip';
import { CoreNetworkService } from '@services/network';
/** /**
* This module handles the emulation of Cordova plugins in browser and desktop. * This module handles the emulation of Cordova plugins in browser and desktop.
@ -152,11 +152,7 @@ import { ZipMock } from './services/zip';
deps: [Platform], deps: [Platform],
useFactory: (platform: Platform): MediaCapture => platform.is('cordova') ? new MediaCapture() : new MediaCaptureMock(), useFactory: (platform: Platform): MediaCapture => platform.is('cordova') ? new MediaCapture() : new MediaCaptureMock(),
}, },
{ CoreNetworkService,
provide: Network,
deps: [Platform],
useFactory: (platform: Platform): Network => platform.is('cordova') ? new Network() : new NetworkMock(),
},
Push, Push,
QRScanner, QRScanner,
SplashScreen, SplashScreen,

View File

@ -1,81 +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 { Injectable } from '@angular/core';
import { Network } from '@ionic-native/network/ngx';
import { Observable, Subject, merge } from 'rxjs';
/**
* Emulates the Cordova Network plugin in browser.
*/
@Injectable()
export class NetworkMock extends Network {
type!: string;
protected connectObservable = new Subject<'connected'>();
protected disconnectObservable = new Subject<'disconnected'>();
constructor() {
super();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(<any> window).Connection = {
UNKNOWN: 'unknown', // eslint-disable-line @typescript-eslint/naming-convention
ETHERNET: 'ethernet', // eslint-disable-line @typescript-eslint/naming-convention
WIFI: 'wifi', // eslint-disable-line @typescript-eslint/naming-convention
CELL_2G: '2g', // eslint-disable-line @typescript-eslint/naming-convention
CELL_3G: '3g', // eslint-disable-line @typescript-eslint/naming-convention
CELL_4G: '4g', // eslint-disable-line @typescript-eslint/naming-convention
CELL: 'cellular', // eslint-disable-line @typescript-eslint/naming-convention
NONE: 'none', // eslint-disable-line @typescript-eslint/naming-convention
};
window.addEventListener('online', () => {
this.connectObservable.next('connected');
}, false);
window.addEventListener('offline', () => {
this.disconnectObservable.next('disconnected');
}, false);
}
/**
* Returns an observable to watch connection changes.
*
* @return Observable.
*/
onChange(): Observable<'connected' | 'disconnected'> {
return merge(this.connectObservable, this.disconnectObservable);
}
/**
* Returns an observable to notify when the app is connected.
*
* @return Observable.
*/
onConnect(): Observable<'connected'> {
return this.connectObservable;
}
/**
* Returns an observable to notify when the app is disconnected.
*
* @return Observable.
*/
onDisconnect(): Observable<'disconnected'> {
return this.disconnectObservable;
}
}

View File

@ -32,8 +32,9 @@ Feature: Test basic usage of login in app
And I press "Connect to your site" in the app And I press "Connect to your site" in the app
Then I should find "Acceptance test site" in the app Then I should find "Acceptance test site" in the app
When I set the field "Username" to "student1" in the app When I set the following fields to these values in the app:
And I set the field "Password" to "student1" in the app | Username | student1 |
| Password | student1 |
And I press "Log in" near "Forgotten your username or password?" in the app And I press "Log in" near "Forgotten your username or password?" in the app
Then I should find "Acceptance test site" in the app Then I should find "Acceptance test site" in the app
But I should not find "Log in" in the app But I should not find "Log in" in the app

View File

@ -431,7 +431,7 @@ export class CorePushNotificationsProvider {
/** /**
* Function called when a push notification is clicked. Redirect the user to the right state. * Function called when a push notification is clicked. Redirect the user to the right state.
* *
* @param notification Notification. * @param data Notification data.
* @return Promise resolved when done. * @return Promise resolved when done.
*/ */
async notificationClicked(data: CorePushNotificationsNotificationBasicData): Promise<void> { async notificationClicked(data: CorePushNotificationsNotificationBasicData): Promise<void> {

View File

@ -16,7 +16,7 @@ import { CoreApp } from '@services/app';
import { Component, OnDestroy } from '@angular/core'; import { Component, OnDestroy } from '@angular/core';
import { CoreConstants } from '@/core/constants'; import { CoreConstants } from '@/core/constants';
import { CoreLocalNotifications } from '@services/local-notifications'; import { CoreLocalNotifications } from '@services/local-notifications';
import { Device, Platform, Translate, Network, NgZone } from '@singletons'; import { Device, Platform, Translate, NgZone } from '@singletons';
import { CoreLang } from '@services/lang'; import { CoreLang } from '@services/lang';
import { CoreFile } from '@services/file'; import { CoreFile } from '@services/file';
import { CoreSites } from '@services/sites'; import { CoreSites } from '@services/sites';
@ -82,7 +82,6 @@ export class CoreSettingsDeviceInfoPage implements OnDestroy {
protected onlineObserver?: Subscription; protected onlineObserver?: Subscription;
constructor() { constructor() {
const appProvider = CoreApp.instance;
const sitesProvider = CoreSites.instance; const sitesProvider = CoreSites.instance;
const device = Device.instance; const device = Device.instance;
const translate = Translate.instance; const translate = Translate.instance;
@ -112,10 +111,10 @@ export class CoreSettingsDeviceInfoPage implements OnDestroy {
if (CorePlatform.isMobile()) { if (CorePlatform.isMobile()) {
this.deviceInfo.deviceType = Platform.is('tablet') ? 'tablet' : 'phone'; this.deviceInfo.deviceType = Platform.is('tablet') ? 'tablet' : 'phone';
if (appProvider.isAndroid()) { if (CoreApp.isAndroid()) {
this.deviceInfo.deviceOs = 'android'; this.deviceInfo.deviceOs = 'android';
this.deviceOsTranslated = 'Android'; this.deviceOsTranslated = 'Android';
} else if (appProvider.isIOS()) { } else if (CoreApp.isIOS()) {
this.deviceInfo.deviceOs = 'ios'; this.deviceInfo.deviceOs = 'ios';
this.deviceOsTranslated = 'iOS'; this.deviceOsTranslated = 'iOS';
} else { } else {
@ -177,7 +176,7 @@ export class CoreSettingsDeviceInfoPage implements OnDestroy {
this.deviceInfo.siteVersion = currentSite?.getInfo()?.release; this.deviceInfo.siteVersion = currentSite?.getInfo()?.release;
// Refresh online status when changes. // Refresh online status when changes.
this.onlineObserver = Network.onChange().subscribe(() => { this.onlineObserver = CoreNetwork.onChange().subscribe(() => {
// Execute the callback in the Angular zone, so change detection doesn't stop working. // Execute the callback in the Angular zone, so change detection doesn't stop working.
NgZone.run(() => { NgZone.run(() => {
this.deviceInfo.networkStatus = CoreNetwork.isOnline() ? 'online' : 'offline'; this.deviceInfo.networkStatus = CoreNetwork.isOnline() ? 'online' : 'offline';

View File

@ -12,35 +12,15 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import { ApplicationRef, NgZone as NgZoneService } from '@angular/core'; import { CoreAppProvider } from '@services/app';
import { CorePushNotifications, CorePushNotificationsProvider } from '@features/pushnotifications/services/pushnotifications';
import { CoreApp, CoreAppProvider } from '@services/app';
import { CoreConfig, CoreConfigProvider } from '@services/config';
import { CoreCronDelegate, CoreCronDelegateService } from '@services/cron';
import { CoreDB, CoreDbProvider } from '@services/db'; import { CoreDB, CoreDbProvider } from '@services/db';
import { CoreCustomURLSchemes, CoreCustomURLSchemesProvider } from '@services/urlschemes';
import { Application, NgZone } from '@singletons';
type AutomatedTestsWindow = Window & { type AutomatedTestsWindow = Window & {
appRef?: ApplicationRef;
appProvider?: CoreAppProvider;
dbProvider?: CoreDbProvider; dbProvider?: CoreDbProvider;
configProvider?: CoreConfigProvider;
cronProvider?: CoreCronDelegateService;
ngZone?: NgZoneService;
pushNotifications?: CorePushNotificationsProvider;
urlSchemes?: CoreCustomURLSchemesProvider;
}; };
function initializeAutomatedTestsWindow(window: AutomatedTestsWindow) { function initializeAutomatedTestsWindow(window: AutomatedTestsWindow) {
window.appRef = Application.instance;
window.appProvider = CoreApp.instance;
window.dbProvider = CoreDB.instance; window.dbProvider = CoreDB.instance;
window.configProvider = CoreConfig.instance;
window.cronProvider = CoreCronDelegate.instance;
window.ngZone = NgZone.instance;
window.pushNotifications = CorePushNotifications.instance;
window.urlSchemes = CoreCustomURLSchemes.instance;
} }
export default function(): void { export default function(): void {

View File

@ -13,11 +13,12 @@
// limitations under the License. // limitations under the License.
import { CoreCronDelegate } from '@services/cron'; import { CoreCronDelegate } from '@services/cron';
import { Network, NgZone } from '@singletons'; import { NgZone } from '@singletons';
import { CoreNetwork } from '@services/network';
export default function(): void { export default function(): void {
// When the app is re-connected, start network handlers that were stopped. // When the app is re-connected, start network handlers that were stopped.
Network.onConnect().subscribe(() => { CoreNetwork.onConnect().subscribe(() => {
// Execute the callback in the Angular zone, so change detection doesn't stop working. // Execute the callback in the Angular zone, so change detection doesn't stop working.
NgZone.run(() => CoreCronDelegate.startNetworkHandlers()); NgZone.run(() => CoreCronDelegate.startNetworkHandlers());
}); });

View File

@ -31,7 +31,7 @@ import { CoreUtils, CoreUtilsOpenFileOptions } from '@services/utils/utils';
import { SQLiteDB } from '@classes/sqlitedb'; import { SQLiteDB } from '@classes/sqlitedb';
import { CoreError } from '@classes/errors/error'; import { CoreError } from '@classes/errors/error';
import { CoreConstants } from '@/core/constants'; import { CoreConstants } from '@/core/constants';
import { ApplicationInit, makeSingleton, Network, NgZone, Translate } from '@singletons'; import { ApplicationInit, makeSingleton, NgZone, Translate } from '@singletons';
import { CoreLogger } from '@singletons/logger'; import { CoreLogger } from '@singletons/logger';
import { import {
APP_SCHEMA, APP_SCHEMA,
@ -150,7 +150,7 @@ export class CoreFilepoolProvider {
this.checkQueueProcessing(); this.checkQueueProcessing();
// Start queue when device goes online. // Start queue when device goes online.
Network.onConnect().subscribe(() => { CoreNetwork.onConnect().subscribe(() => {
// Execute the callback in the Angular zone, so change detection doesn't stop working. // Execute the callback in the Angular zone, so change detection doesn't stop working.
NgZone.run(() => this.checkQueueProcessing()); NgZone.run(() => this.checkQueueProcessing());
}); });

View File

@ -14,15 +14,56 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { CorePlatform } from '@services/platform'; import { CorePlatform } from '@services/platform';
import { makeSingleton, Network } from '@singletons'; import { Network as NetworkService } from '@ionic-native/network/ngx';
import { makeSingleton } from '@singletons';
import { Observable, Subject, merge } from 'rxjs';
const Network = makeSingleton(NetworkService);
/** /**
* Service to manage network information. * Service to manage network connections.
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class CoreNetworkService { export class CoreNetworkService extends NetworkService {
type!: string;
protected connectObservable = new Subject<'connected'>();
protected disconnectObservable = new Subject<'disconnected'>();
protected forceOffline = false; protected forceOffline = false;
protected online = false;
constructor() {
super();
this.checkOnline();
if (CorePlatform.isMobile()) {
Network.onChange().subscribe(() => {
this.fireObservable();
});
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(<any> window).Connection = {
UNKNOWN: 'unknown', // eslint-disable-line @typescript-eslint/naming-convention
ETHERNET: 'ethernet', // eslint-disable-line @typescript-eslint/naming-convention
WIFI: 'wifi', // eslint-disable-line @typescript-eslint/naming-convention
CELL_2G: '2g', // eslint-disable-line @typescript-eslint/naming-convention
CELL_3G: '3g', // eslint-disable-line @typescript-eslint/naming-convention
CELL_4G: '4g', // eslint-disable-line @typescript-eslint/naming-convention
CELL: 'cellular', // eslint-disable-line @typescript-eslint/naming-convention
NONE: 'none', // eslint-disable-line @typescript-eslint/naming-convention
};
window.addEventListener('online', () => {
this.fireObservable();
}, false);
window.addEventListener('offline', () => {
this.fireObservable();
}, false);
}
}
/** /**
* Set value of forceOffline flag. If true, the app will think the device is offline. * Set value of forceOffline flag. If true, the app will think the device is offline.
@ -31,6 +72,7 @@ export class CoreNetworkService {
*/ */
setForceOffline(value: boolean): void { setForceOffline(value: boolean): void {
this.forceOffline = !!value; this.forceOffline = !!value;
this.fireObservable();
} }
/** /**
@ -39,23 +81,77 @@ export class CoreNetworkService {
* @return Whether the app is online. * @return Whether the app is online.
*/ */
isOnline(): boolean { isOnline(): boolean {
return this.online;
}
/**
* Returns whether we are online.
*
* @return Whether the app is online.
*/
checkOnline(): void {
if (this.forceOffline) { if (this.forceOffline) {
return false; this.online = false;
return;
} }
if (!CorePlatform.isMobile()) { if (!CorePlatform.isMobile()) {
return navigator.onLine; this.online = navigator.onLine;
return;
} }
let online = Network.type !== null && Network.type != Network.Connection.NONE && let online = this.type !== null && this.type != this.Connection.NONE &&
Network.type != Network.Connection.UNKNOWN; this.type != this.Connection.UNKNOWN;
// Double check we are not online because we cannot rely 100% in Cordova APIs. // Double check we are not online because we cannot rely 100% in Cordova APIs.
if (!online && navigator.onLine) { if (!online && navigator.onLine) {
online = true; online = true;
} }
return online; this.online = online;
}
/**
* Returns an observable to watch connection changes.
*
* @return Observable.
*/
onChange(): Observable<'connected' | 'disconnected'> {
return merge(this.connectObservable, this.disconnectObservable);
}
/**
* Returns an observable to notify when the app is connected.
*
* @return Observable.
*/
onConnect(): Observable<'connected'> {
return this.connectObservable;
}
/**
* Returns an observable to notify when the app is disconnected.
*
* @return Observable.
*/
onDisconnect(): Observable<'disconnected'> {
return this.disconnectObservable;
}
/**
* Fires the correct observable depending on the connection status.
*/
protected fireObservable(): void {
const previousOnline = this.online;
this.checkOnline();
if (this.online && !previousOnline) {
this.connectObservable.next('connected');
} else if (!this.online && previousOnline) {
this.disconnectObservable.next('disconnected');
}
} }
/** /**

View File

@ -25,7 +25,7 @@ import { CoreDomUtils } from '@services/utils/dom';
import { CoreUrlUtils } from '@services/utils/url'; import { CoreUrlUtils } from '@services/utils/url';
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
import { makeSingleton, Network, NgZone, Translate, Diagnostic } from '@singletons'; import { makeSingleton, NgZone, Translate, Diagnostic } from '@singletons';
import { CoreLogger } from '@singletons/logger'; import { CoreLogger } from '@singletons/logger';
import { CoreUrl } from '@singletons/url'; import { CoreUrl } from '@singletons/url';
import { CoreWindow } from '@singletons/window'; import { CoreWindow } from '@singletons/window';
@ -76,7 +76,7 @@ export class CoreIframeUtilsProvider {
this.addOfflineWarning(element, src, isSubframe); this.addOfflineWarning(element, src, isSubframe);
// If the network changes, check it again. // If the network changes, check it again.
const subscription = Network.onConnect().subscribe(() => { const subscription = CoreNetwork.onConnect().subscribe(() => {
// Execute the callback in the Angular zone, so change detection doesn't stop working. // Execute the callback in the Angular zone, so change detection doesn't stop working.
NgZone.run(() => { NgZone.run(() => {
if (!this.checkOnlineFrameInOffline(element, isSubframe)) { if (!this.checkOnlineFrameInOffline(element, isSubframe)) {

View File

@ -187,6 +187,9 @@ export const LocalNotifications = makeSingleton(LocalNotificationsService);
export const Media = makeSingleton(MediaService); export const Media = makeSingleton(MediaService);
export const MediaCapture = makeSingleton(MediaCaptureService); export const MediaCapture = makeSingleton(MediaCaptureService);
export const NativeHttp = makeSingleton(HTTP); export const NativeHttp = makeSingleton(HTTP);
/**
* @deprecated on 4.1 use CoreNetwork instead.
*/
export const Network = makeSingleton(NetworkService); export const Network = makeSingleton(NetworkService);
export const Push = makeSingleton(PushService); export const Push = makeSingleton(PushService);
export const QRScanner = makeSingleton(QRScannerService); export const QRScanner = makeSingleton(QRScannerService);

View File

@ -12,9 +12,9 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import { CorePromisedValue } from '@classes/promised-value';
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
import { NgZone } from '@singletons'; import { NgZone } from '@singletons';
import { TestsBehatBlocking } from './behat-blocking';
import { TestBehatElementLocator } from './behat-runtime'; import { TestBehatElementLocator } from './behat-runtime';
// Containers that block containers behind them. // Containers that block containers behind them.
@ -82,7 +82,7 @@ export class TestsBehatDomUtils {
* @return Elements containing the given text with exact boolean. * @return Elements containing the given text with exact boolean.
*/ */
protected static findElementsBasedOnTextWithinWithExact(container: HTMLElement, text: string): ElementsWithExact[] { protected static findElementsBasedOnTextWithinWithExact(container: HTMLElement, text: string): ElementsWithExact[] {
const attributesSelector = `[aria-label*="${text}"], a[title*="${text}"], img[alt*="${text}"]`; const attributesSelector = `[aria-label*="${text}"], a[title*="${text}"], img[alt*="${text}"], [placeholder*="${text}"]`;
const elements = Array.from(container.querySelectorAll<HTMLElement>(attributesSelector)) const elements = Array.from(container.querySelectorAll<HTMLElement>(attributesSelector))
.filter((element => this.isElementVisible(element, container))) .filter((element => this.isElementVisible(element, container)))
@ -104,7 +104,9 @@ export class TestsBehatDomUtils {
} }
if (node instanceof HTMLElement && if (node instanceof HTMLElement &&
(node.getAttribute('aria-hidden') === 'true' || getComputedStyle(node).display === 'none')) { (node.getAttribute('aria-hidden') === 'true' ||
node.getAttribute('aria-disabled') === 'true' ||
getComputedStyle(node).display === 'none')) {
return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_REJECT;
} }
@ -176,7 +178,8 @@ export class TestsBehatDomUtils {
protected static checkElementLabel(element: HTMLElement, text: string): boolean { protected static checkElementLabel(element: HTMLElement, text: string): boolean {
return element.title === text || return element.title === text ||
element.getAttribute('alt') === text || element.getAttribute('alt') === text ||
element.getAttribute('aria-label') === text; element.getAttribute('aria-label') === text ||
element.getAttribute('placeholder') === text;
} }
/** /**
@ -219,7 +222,7 @@ export class TestsBehatDomUtils {
} }
return Array.from(uniqueElements); return Array.from(uniqueElements);
}; }
/** /**
* Get parent element, including Shadow DOM parents. * Get parent element, including Shadow DOM parents.
@ -359,7 +362,7 @@ export class TestsBehatDomUtils {
* Function to find elements based on their text or Aria label. * Function to find elements based on their text or Aria label.
* *
* @param locator Element locator. * @param locator Element locator.
* @param container Container to search in. * @param topContainer Container to search in.
* @return Found elements * @return Found elements
*/ */
protected static findElementsBasedOnTextInContainer( protected static findElementsBasedOnTextInContainer(
@ -377,7 +380,7 @@ export class TestsBehatDomUtils {
const withinElementsAncestors = this.getTopAncestors(withinElements); const withinElementsAncestors = this.getTopAncestors(withinElements);
if (withinElementsAncestors.length > 1) { if (withinElementsAncestors.length > 1) {
throw new Error('Too many matches for within text'); throw new Error('Too many matches for within text ('+withinElementsAncestors.length+')');
} }
topContainer = container = withinElementsAncestors[0]; topContainer = container = withinElementsAncestors[0];
@ -395,7 +398,7 @@ export class TestsBehatDomUtils {
const nearElementsAncestors = this.getTopAncestors(nearElements); const nearElementsAncestors = this.getTopAncestors(nearElements);
if (nearElementsAncestors.length > 1) { if (nearElementsAncestors.length > 1) {
throw new Error('Too many matches for near text'); throw new Error('Too many matches for near text ('+nearElementsAncestors.length+')');
} }
container = this.getParentElement(nearElementsAncestors[0]); container = this.getParentElement(nearElementsAncestors[0]);
@ -444,21 +447,23 @@ export class TestsBehatDomUtils {
element.scrollIntoView(false); element.scrollIntoView(false);
return new Promise<DOMRect>((resolve): void => { const promise = new CorePromisedValue<DOMRect>();
requestAnimationFrame(() => { requestAnimationFrame(() => {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
if (initialRect.y !== rect.y) { if (initialRect.y !== rect.y) {
setTimeout(() => { setTimeout(() => {
resolve(rect); promise.resolve(rect);
}, 300); }, 300);
return; return;
} }
resolve(rect); promise.resolve(rect);
});
}); });
return promise;
}; };
/** /**
@ -467,8 +472,8 @@ export class TestsBehatDomUtils {
* @param element Element to press. * @param element Element to press.
*/ */
static async pressElement(element: HTMLElement): Promise<void> { static async pressElement(element: HTMLElement): Promise<void> {
NgZone.run(async () => { await NgZone.run(async () => {
const blockKey = TestsBehatBlocking.block(); const promise = new CorePromisedValue<void>();
// Events don't bubble up across Shadow DOM boundaries, and some buttons // Events don't bubble up across Shadow DOM boundaries, and some buttons
// may not work without doing this. // may not work without doing this.
@ -498,8 +503,10 @@ export class TestsBehatDomUtils {
element.dispatchEvent(new MouseEvent('mouseup', eventOptions)); element.dispatchEvent(new MouseEvent('mouseup', eventOptions));
element.click(); element.click();
TestsBehatBlocking.unblock(blockKey); promise.resolve();
}, 300); }, 300);
return promise;
}); });
} }
@ -509,22 +516,33 @@ export class TestsBehatDomUtils {
* @param element HTML to set. * @param element HTML to set.
* @param value Value to be set. * @param value Value to be set.
*/ */
static async setElementValue(element: HTMLElement, value: string): Promise<void> { static async setElementValue(element: HTMLInputElement | HTMLElement, value: string): Promise<void> {
NgZone.run(async () => { await NgZone.run(async () => {
const blockKey = TestsBehatBlocking.block(); const promise = new CorePromisedValue<void>();
// Functions to get/set value depending on field type. // Functions to get/set value depending on field type.
let setValue = (text: string) => { const setValue = (text: string) => {
element.innerHTML = text; if (element.tagName === 'ION-SELECT' && 'value' in element) {
}; value = value.trim();
let getValue = () => element.innerHTML; const optionValue = Array.from(element.querySelectorAll('ion-select-option'))
.find((option) => option.innerHTML.trim() === value);
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) { if (optionValue) {
setValue = (text: string) => { element.value = optionValue.value;
element.value = text;
};
getValue = () => element.value;
} }
} else if ('value' in element) {
element.value = text;
} else {
element.innerHTML = text;
}
};
const getValue = () => {
if ('value' in element) {
return element.value;
} else {
return element.innerHTML;
}
};
// Pretend we have cut and pasted the new text. // Pretend we have cut and pasted the new text.
let event: InputEvent; let event: InputEvent;
@ -555,7 +573,9 @@ export class TestsBehatDomUtils {
element.dispatchEvent(event); element.dispatchEvent(event);
} }
TestsBehatBlocking.unblock(blockKey); promise.resolve();
return promise;
}); });
} }

View File

@ -18,6 +18,16 @@ import { CoreCustomURLSchemes } from '@services/urlschemes';
import { CoreLoginHelperProvider } from '@features/login/services/login-helper'; import { CoreLoginHelperProvider } from '@features/login/services/login-helper';
import { CoreConfig } from '@services/config'; import { CoreConfig } from '@services/config';
import { EnvironmentConfig } from '@/types/config'; import { EnvironmentConfig } from '@/types/config';
import { NgZone } from '@singletons';
import { CoreNetwork } from '@services/network';
import {
CorePushNotifications,
CorePushNotificationsNotificationBasicData,
} from '@features/pushnotifications/services/pushnotifications';
import { CoreCronDelegate } from '@services/cron';
import { CoreLoadingComponent } from '@components/loading/loading';
import { CoreComponentsRegistry } from '@singletons/components-registry';
import { CoreDom } from '@singletons/dom';
/** /**
* Behat runtime servive with public API. * Behat runtime servive with public API.
@ -45,6 +55,10 @@ export class TestsBehatRuntime {
scrollTo: TestsBehatRuntime.scrollTo, scrollTo: TestsBehatRuntime.scrollTo,
setField: TestsBehatRuntime.setField, setField: TestsBehatRuntime.setField,
handleCustomURL: TestsBehatRuntime.handleCustomURL, handleCustomURL: TestsBehatRuntime.handleCustomURL,
notificationClicked: TestsBehatRuntime.notificationClicked,
forceSyncExecution: TestsBehatRuntime.forceSyncExecution,
waitLoadingToFinish: TestsBehatRuntime.waitLoadingToFinish,
network: CoreNetwork.instance,
}; };
if (!options) { if (!options) {
@ -69,26 +83,69 @@ export class TestsBehatRuntime {
* @return OK if successful, or ERROR: followed by message. * @return OK if successful, or ERROR: followed by message.
*/ */
static async handleCustomURL(url: string): Promise<string> { static async handleCustomURL(url: string): Promise<string> {
const blockKey = TestsBehatBlocking.block();
try { try {
await NgZone.run(async () => {
await CoreCustomURLSchemes.handleCustomURL(url); await CoreCustomURLSchemes.handleCustomURL(url);
});
return 'OK'; return 'OK';
} catch (error) { } catch (error) {
return 'ERROR: ' + error.message; return 'ERROR: ' + error.message;
}
}
/**
* Function called when a push notification is clicked. Redirect the user to the right state.
*
* @param data Notification data.
* @return Promise resolved when done.
*/
static async notificationClicked(data: CorePushNotificationsNotificationBasicData): Promise<void> {
const blockKey = TestsBehatBlocking.block();
try {
await NgZone.run(async () => {
await CorePushNotifications.notificationClicked(data);
});
} finally { } finally {
TestsBehatBlocking.unblock(blockKey); TestsBehatBlocking.unblock(blockKey);
} }
} }
/**
* Force execution of synchronization cron tasks without waiting for the scheduled time.
* Please notice that some tasks may not be executed depending on the network connection and sync settings.
*
* @return Promise resolved if all handlers are executed successfully, rejected otherwise.
*/
static async forceSyncExecution(): Promise<void> {
await NgZone.run(async () => {
await CoreCronDelegate.forceSyncExecution();
});
}
/**
* Wait all controlled components to be rendered.
*
* @return Promise resolved when all components have been rendered.
*/
static async waitLoadingToFinish(): Promise<void> {
await NgZone.run(async () => {
const elements = Array.from(document.body.querySelectorAll<HTMLElement>('core-loading'))
.filter((element) => CoreDom.isElementVisible(element));
await Promise.all(elements.map(element =>
CoreComponentsRegistry.waitComponentReady(element, CoreLoadingComponent)));
});
}
/** /**
* Function to find and click an app standard button. * Function to find and click an app standard button.
* *
* @param button Type of button to press. * @param button Type of button to press.
* @return OK if successful, or ERROR: followed by message. * @return OK if successful, or ERROR: followed by message.
*/ */
static pressStandard(button: string): string { static async pressStandard(button: string): Promise<string> {
this.log('Action - Click standard button: ' + button); this.log('Action - Click standard button: ' + button);
// Find button // Find button
@ -120,7 +177,7 @@ export class TestsBehatRuntime {
} }
// Click button // Click button
TestsBehatDomUtils.pressElement(foundButton); await TestsBehatDomUtils.pressElement(foundButton);
return 'OK'; return 'OK';
} }
@ -140,7 +197,7 @@ export class TestsBehatRuntime {
return 'ERROR: Could not find backdrop'; return 'ERROR: Could not find backdrop';
} }
if (backdrops.length > 1) { if (backdrops.length > 1) {
return 'ERROR: Found too many backdrops'; return 'ERROR: Found too many backdrops ('+backdrops.length+')';
} }
const backdrop = backdrops[0]; const backdrop = backdrops[0];
backdrop.click(); backdrop.click();
@ -274,7 +331,7 @@ export class TestsBehatRuntime {
* @param locator Element locator. * @param locator Element locator.
* @return OK if successful, or ERROR: followed by message * @return OK if successful, or ERROR: followed by message
*/ */
static press(locator: TestBehatElementLocator): string { static async press(locator: TestBehatElementLocator): Promise<string> {
this.log('Action - Press', locator); this.log('Action - Press', locator);
try { try {
@ -284,7 +341,7 @@ export class TestsBehatRuntime {
return 'ERROR: No element matches locator to press.'; return 'ERROR: No element matches locator to press.';
} }
TestsBehatDomUtils.pressElement(found); await TestsBehatDomUtils.pressElement(found);
return 'OK'; return 'OK';
} catch (error) { } catch (error) {
@ -304,7 +361,7 @@ export class TestsBehatRuntime {
titles = titles.filter((title) => TestsBehatDomUtils.isElementVisible(title, document.body)); titles = titles.filter((title) => TestsBehatDomUtils.isElementVisible(title, document.body));
if (titles.length > 1) { if (titles.length > 1) {
return 'ERROR: Too many possible titles.'; return 'ERROR: Too many possible titles ('+titles.length+').';
} else if (!titles.length) { } else if (!titles.length) {
return 'ERROR: No title found.'; return 'ERROR: No title found.';
} else { } else {
@ -323,18 +380,18 @@ export class TestsBehatRuntime {
* @param value New value * @param value New value
* @return OK or ERROR: followed by message * @return OK or ERROR: followed by message
*/ */
static setField(field: string, value: string): string { static async setField(field: string, value: string): Promise<string> {
this.log('Action - Set field ' + field + ' to: ' + value); this.log('Action - Set field ' + field + ' to: ' + value);
const found: HTMLElement | HTMLInputElement | HTMLTextAreaElement =TestsBehatDomUtils.findElementBasedOnText( const found: HTMLElement | HTMLInputElement = TestsBehatDomUtils.findElementBasedOnText(
{ text: field, selector: 'input, textarea, [contenteditable="true"]' }, { text: field, selector: 'input, textarea, [contenteditable="true"], ion-select' },
); );
if (!found) { if (!found) {
return 'ERROR: No element matches field to set.'; return 'ERROR: No element matches field to set.';
} }
TestsBehatDomUtils.setElementValue(found, value); await TestsBehatDomUtils.setElementValue(found, value);
return 'OK'; return 'OK';
} }

View File

@ -19,11 +19,12 @@ import { Observable, Subject } from 'rxjs';
import { sep } from 'path'; import { sep } from 'path';
import { CORE_SITE_SCHEMAS } from '@services/sites'; import { CORE_SITE_SCHEMAS } from '@services/sites';
import { CoreSingletonProxy, Network, Platform, Translate } from '@singletons'; import { CoreSingletonProxy, Platform, Translate } from '@singletons';
import { CoreTextUtilsProvider } from '@services/utils/text'; import { CoreTextUtilsProvider } from '@services/utils/text';
import { TranslatePipeStub } from './stubs/pipes/translate'; import { TranslatePipeStub } from './stubs/pipes/translate';
import { CoreExternalContentDirectiveStub } from './stubs/directives/core-external-content'; import { CoreExternalContentDirectiveStub } from './stubs/directives/core-external-content';
import { CoreNetwork } from '@services/network';
abstract class WrapperComponent<U> { abstract class WrapperComponent<U> {
@ -37,7 +38,7 @@ let testBedInitialized = false;
const textUtils = new CoreTextUtilsProvider(); const textUtils = new CoreTextUtilsProvider();
const DEFAULT_SERVICE_SINGLETON_MOCKS: [CoreSingletonProxy, Record<string, unknown>][] = [ const DEFAULT_SERVICE_SINGLETON_MOCKS: [CoreSingletonProxy, Record<string, unknown>][] = [
[Platform, mock({ is: () => false, ready: () => Promise.resolve(), resume: new Subject<void>() })], [Platform, mock({ is: () => false, ready: () => Promise.resolve(), resume: new Subject<void>() })],
[Network, { onChange: () => new Observable() }], [CoreNetwork, { onChange: () => new Observable() }],
]; ];
async function renderAngularComponent<T>(component: Type<T>, config: RenderConfig): Promise<ComponentFixture<T>> { async function renderAngularComponent<T>(component: Type<T>, config: RenderConfig): Promise<ComponentFixture<T>> {

View File

@ -27,148 +27,3 @@ ion-content {
font-weight: normal; font-weight: normal;
font-size: 0.9rem; font-size: 0.9rem;
} }
// Message item.
ion-item.addon-message {
--message-background: var(--addon-messages-message-bg);
--message-activated-background: var(--addon-messages-message-activated-bg);
--message-alignment: flex-start;
border: 0;
border-radius: var(--medium-radius);
padding: 0 8px 0 8px;
margin: 8px;
--background: var(--message-background);
background: var(--message-background);
align-self: var(--message-alignment);
width: 90%;
max-width: var(--list-item-max-width);
--min-height: var(--a11y-min-target-size);
position: relative;
@include core-transition(width);
// This is needed to display bubble tails.
overflow: visible;
&::part(native) {
--inner-border-width: 0px;
--inner-padding-end: 0px;
padding: 0;
margin: 0;
}
&:hover {
-webkit-filter: drop-shadow(2px 2px 2px rgba(0,0,0,.3));
filter: drop-shadow(2px 2px 2px rgba(0,0,0,.3));
}
core-format-text > p:only-child {
display: inline;
}
.addon-message-user {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: .5rem;
margin-top: 0;
color: var(--ion-text-color);
core-user-avatar {
display: block;
--core-avatar-size: var(--addon-messages-avatar-size);
margin: 0;
}
div {
font-weight: 500;
flex-grow: 1;
padding-left: .5rem;
padding-right: .5rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
ion-note {
color: var(--addon-messages-message-note-text);
font-size: var(--addon-messages-message-note-font-size);
margin: 0;
padding: 8px 0;
align-self: flex-start;
}
&[tappable]:active {
--message-background: var(--message-activated-background);
}
ion-label {
margin: 0;
padding: 8px 0;
}
.addon-message-text {
display: inline-flex;
* {
color: var(--ion-text-color);
}
}
.tail {
content: '';
width: 0;
height: 0;
border: 0.5rem solid transparent;
position: absolute;
touch-action: none;
bottom: 0;
border-bottom-color: var(--message-background);
}
// Defines when an item-message is the user's.
&.addon-message-mine {
--message-background: var(--addon-messages-message-mine-bg);
--message-activated-background: var(--addon-messages-message-mine-activated-bg);
--message-alignment: flex-end;
.spinner {
@include float(end);
@include margin(2px, -3px, -2px, 5px);
svg {
width: 16px;
height: 16px;
}
}
.tail {
@include position(null, -8px, null, null);
@include margin-horizontal(null, -0.5rem);
}
}
&.addon-message-not-mine .tail {
@include position(null, null, null, -8px);
@include margin-horizontal(-0.5rem, null);
}
.addon-messages-delete-button {
min-height: initial;
line-height: initial;
margin-top: 0px;
margin-bottom: 0px;
height: var(--a11y-min-target-size) !important;
align-self: flex-end;
ion-icon {
font-size: 1.4em;
line-height: initial;
color: var(--danger);
}
}
&.addon-message-no-user {
margin-top: 0px;
}
}

View File

@ -146,13 +146,13 @@
--core-collapsible-footer-background: var(--contrast-background); --core-collapsible-footer-background: var(--contrast-background);
--addon-messages-message-bg: var(--gray-800); --core-messages-message-bg: var(--gray-800);
--addon-messages-message-activated-bg: var(--gray-700); --core-messages-message-activated-bg: var(--gray-700);
--addon-messages-message-note-text: var(--subdued-text-color); --core-messages-message-note-text: var(--subdued-text-color);
--addon-messages-message-mine-bg: var(--gray-700); --core-messages-message-mine-bg: var(--gray-700);
--addon-messages-message-mine-activated-bg: var(--gray-600); --core-messages-message-mine-activated-bg: var(--gray-600);
--addon-messages-discussion-badge: var(--primary); --core-messages-discussion-badge: var(--primary);
--addon-messages-discussion-badge-text: var(--gray-100); --core-messages-discussion-badge-text: var(--gray-100);
--addon-forum-border-color: var(--gray-500); --addon-forum-border-color: var(--gray-500);
--addon-forum-highlight-color: var(--gray-200); --addon-forum-highlight-color: var(--gray-200);

View File

@ -344,15 +344,15 @@
--addon-calendar-today-border-color: var(--primary); --addon-calendar-today-border-color: var(--primary);
--addon-calendar-border-color: var(--stroke); --addon-calendar-border-color: var(--stroke);
--addon-messages-message-bg: var(--white); --core-messages-message-bg: var(--white);
--addon-messages-message-activated-bg: var(--gray-200); --core-messages-message-activated-bg: var(--gray-200);
--addon-messages-message-note-text: var(--gray-500); --core-messages-message-note-text: var(--gray-500);
--addon-messages-message-note-font-size: 75%; --core-messages-message-note-font-size: 75%;
--addon-messages-message-mine-bg: var(--gray-300); --core-messages-message-mine-bg: var(--gray-300);
--addon-messages-message-mine-activated-bg: var(--gray-400); --core-messages-message-mine-activated-bg: var(--gray-400);
--addon-messages-avatar-size: 30px; --core-messages-avatar-size: 30px;
--addon-messages-discussion-badge: var(--primary); --core-messages-discussion-badge: var(--primary);
--addon-messages-discussion-badge-text: var(--white); --core-messages-discussion-badge-text: var(--white);
--addon-forum-avatar-size: var(--core-avatar-size); --addon-forum-avatar-size: var(--core-avatar-size);
--addon-forum-border-color: var(--stroke); --addon-forum-border-color: var(--stroke);

View File

@ -1,9 +1,10 @@
This files describes API changes in the Moodle Mobile app, This files describes API changes in the Moodle Mobile app,
information provided here is intended especially for developers. information provided here is intended especially for developers.
=== 4.0.1 === === 4.1.0 ===
- Zoom levels changed from "normal / low / high" to " none / medium / high". - Zoom levels changed from "normal / low / high" to " none / medium / high".
- --addon-messages-* CSS3 variables have been renamed to --core-messages-*
=== 4.0.0 === === 4.0.0 ===