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() {
// Wait window to reload.
$this->spin(function() {
$result = $this->evaluate_script("return !window.behat;");
$result = $this->js("return !window.behat;");
if (!$result) {
throw new DriverException('Window is not reloading properly.');
@ -121,7 +121,7 @@ class behat_app extends behat_app_helper {
$containerName = json_encode($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') {
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);
$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') {
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) {
$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.') {
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) {
$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();
@ -214,7 +214,7 @@ class behat_app extends behat_app_helper {
$locator = $this->parse_element_locator($locator);
$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) {
case 'YES':
@ -318,7 +318,7 @@ class behat_app extends behat_app_helper {
$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') {
// 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) {
$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') {
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();
}
@ -494,7 +494,7 @@ class behat_app extends behat_app_helper {
*/
public function i_close_the_popup_in_the_app() {
$this->spin(function() {
$result = $this->evaluate_script("return window.behat.closePopup();");
$result = $this->js("return window.behat.closePopup();");
if ($result !== 'OK') {
throw new DriverException('Error closing popup - ' . $result);
@ -532,7 +532,7 @@ class behat_app extends behat_app_helper {
$locator = $this->parse_element_locator($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') {
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) {
// 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) {
return true;
}
// Press item.
$result = $this->evaluate_script("return window.behat.press($locator);");
$result = $this->js("return await window.behat.press($locator);");
if ($result !== 'OK') {
throw new DriverException('Error pressing item - ' . $result);
@ -578,7 +578,7 @@ class behat_app extends behat_app_helper {
// Check that it worked as expected.
$this->wait_for_pending_js();
$result = $this->evaluate_script("return window.behat.isSelected($locator);");
$result = $this->js("return window.behat.isSelected($locator);");
switch ($result) {
case 'YES':
@ -612,7 +612,7 @@ class behat_app extends behat_app_helper {
$value = addslashes_js($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') {
throw new DriverException('Error setting field - ' . $result);
@ -624,6 +624,21 @@ class behat_app extends behat_app_helper {
$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.
*
@ -636,7 +651,7 @@ class behat_app extends behat_app_helper {
*/
public function the_header_should_be_in_the_app(string $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:') {
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
*/
public function i_run_cron_tasks_in_the_app() {
$session = $this->getSession();
// 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();
$this->js('await window.behat.forceSyncExecution()');
$this->wait_for_pending_js();
}
/**
@ -744,28 +742,8 @@ class behat_app extends behat_app_helper {
* @When I wait loading to finish in the app
*/
public function i_wait_loading_to_finish_in_the_app() {
$session = $this->getSession();
$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)
);
$this->js('await window.behat.waitLoadingToFinish()');
$this->wait_for_pending_js();
}
/**
@ -786,7 +764,7 @@ class behat_app extends behat_app_helper {
$this->getSession()->switchToWindow($names[1]);
}
$this->execute_script('window.close();');
$this->js('window.close();');
$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
*/
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->configOverrides = $this->appconfig;
$this->execute_script('window.behatInit(' . json_encode($initOptions) . ');');
$this->js('window.behatInit(' . json_encode($initOptions) . ');');
} catch (Exception $error) {
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.
*/
protected function trigger_angular_change_detection() {
$this->getSession()->executeScript('ngZone.run(() => {});');
}
/**
* Evaluate a script that returns a Promise.
* Evaluate and execute scripts checking for promises if needed.
*
* @param string $script
* @return mixed Resolved promise result.
*/
protected function evaluate_async_script(string $script) {
$script = preg_replace('/^return\s+/', '', $script);
$script = preg_replace('/;$/', '', $script);
protected function js(string $script) {
$scriptnoreturn = preg_replace('/^return\s+/', '', $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);
$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)
.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");
}
// 0.1 seconds.
usleep(100000);
} while (!$this->evaluate_script("return '$promisevariable' in window;"));
@ -522,7 +523,7 @@ class behat_app_helper extends behat_base {
$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();
$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 $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.
$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)) {
// 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');
}, 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 { AddonCalendarFilterComponent } from '../../components/filter/filter';
import moment from 'moment';
import { Network, NgZone } from '@singletons';
import { NgZone } from '@singletons';
import { CoreNavigator } from '@services/navigator';
import { Params } from '@angular/router';
import { Subscription } from 'rxjs';
@ -180,7 +180,7 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy {
);
// 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.
NgZone.run(() => {
this.isOnline = CoreNetwork.isOnline();

View File

@ -32,7 +32,7 @@ import { CoreLocalNotifications } from '@services/local-notifications';
import { CoreCourse } from '@features/course/services/course';
import { CoreTimeUtils } from '@services/utils/time';
import { CoreGroups } from '@services/groups';
import { Network, NgZone, Translate } from '@singletons';
import { NgZone, Translate } from '@singletons';
import { Subscription } from 'rxjs';
import { CoreNavigator } from '@services/navigator';
import { CoreUtils } from '@services/utils/utils';
@ -123,7 +123,7 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy {
);
// 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.
NgZone.run(() => {
this.isOnline = CoreNetwork.isOnline();

View File

@ -23,7 +23,7 @@ import { AddonCalendar, AddonCalendarProvider } from '../../services/calendar';
import { AddonCalendarOffline } from '../../services/calendar-offline';
import { AddonCalendarSync, AddonCalendarSyncProvider } from '../../services/calendar-sync';
import { AddonCalendarFilter, AddonCalendarHelper } from '../../services/calendar-helper';
import { Network, NgZone } from '@singletons';
import { NgZone } from '@singletons';
import { Subscription } from 'rxjs';
import { CoreEnrolledCourseData } from '@features/courses/services/courses';
import { ActivatedRoute, Params } from '@angular/router';
@ -153,7 +153,7 @@ export class AddonCalendarIndexPage implements OnInit, OnDestroy {
);
// 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.
NgZone.run(() => {
this.isOnline = CoreNetwork.isOnline();

View File

@ -32,7 +32,8 @@ import { CorePushNotificationsDelegate } from '@features/pushnotifications/servi
import { AddonMessagesPushClickHandler } from './services/handlers/push-click';
import { CoreUserDelegate } from '@features/user/services/user-delegate';
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 { AddonMessagesSyncCronHandler } from './services/handlers/sync-cron';
import { CoreSitePreferencesRoutingModule } from '@features/settings/pages/site/site-routing';
@ -86,7 +87,7 @@ const preferencesRoutes: Routes = [
CoreUserDelegate.registerHandler(AddonMessagesSendMessageUserHandler.instance);
// 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.
NgZone.run(() => {
AddonMessagesSync.syncAllDiscussions(undefined, true);

View File

@ -81,47 +81,16 @@
<ion-icon name="fas-arrow-down" aria-hidden="true"></ion-icon>
</ion-chip>
<ion-item class="ion-text-wrap addon-message" (longPress)="copyMessage(message)"
[class.addon-message-mine]="message.useridfrom == currentUserId"
[class.addon-message-not-mine]="message.useridfrom != currentUserId"
[class.addon-message-no-user]="!message.showUserData"
[@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>
<core-message [message]="message" [user]="members[message.useridfrom]" (afterRender)="last && scrollToBottom()"
[text]="message.text" (onDeleteMessage)="deleteMessage(message, index)" [showDelete]="showDelete"
[time]="message.timecreated">
</core-message>
</ng-container>
</ion-list>
<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>
<!-- Scroll bottom. -->
<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 { Md5 } from 'ts-md5/dist/md5';
import moment from 'moment';
import { CoreAnimations } from '@components/animations';
import { CoreError } from '@classes/errors/error';
import { Translate } from '@singletons';
import { CoreNavigator } from '@services/navigator';
@ -53,7 +52,6 @@ import { CoreDom } from '@singletons/dom';
@Component({
selector: 'page-addon-messages-discussion',
templateUrl: 'discussion.html',
animations: [CoreAnimations.SLIDE_IN_OUT],
styleUrls: ['discussion.scss'],
})
export class AddonMessagesDiscussionPage implements OnInit, OnDestroy, AfterViewInit {
@ -305,7 +303,7 @@ export class AddonMessagesDiscussionPage implements OnInit, OnDestroy, AfterView
} else {
if (this.userId) {
// 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 = {
id: user.id,
fullname: user.fullname,
@ -524,7 +522,7 @@ export class AddonMessagesDiscussionPage implements OnInit, OnDestroy, AfterView
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)
.reverse();
@ -555,7 +553,7 @@ export class AddonMessagesDiscussionPage implements OnInit, OnDestroy, AfterView
// Try to get the conversationId if we don't have it.
if (!conversationId && userId) {
try {
if (userId == this.currentUserId && AddonMessages.isSelfConversationEnabled()) {
if (userId === this.currentUserId && AddonMessages.isSelfConversationEnabled()) {
fallbackConversation = await AddonMessages.getSelfConversation();
} else {
fallbackConversation = await AddonMessages.getConversationBetweenUsers(userId, undefined, true);
@ -563,7 +561,7 @@ export class AddonMessagesDiscussionPage implements OnInit, OnDestroy, AfterView
conversationId = fallbackConversation.id;
} catch (error) {
// 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);
@ -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.
await AddonMessages.invalidateConversation(conversationId!);
await AddonMessages.invalidateConversation(conversationId);
try {
this.conversation = await AddonMessages.getConversation(conversationId!, undefined, true);
this.conversation = await AddonMessages.getConversation(conversationId, undefined, true);
} catch (error) {
// Get conversation failed, use the fallback one if we have it.
if (fallbackConversation) {
@ -947,7 +949,6 @@ export class AddonMessagesDiscussionPage implements OnInit, OnDestroy, AfterView
message: AddonMessagesConversationMessageFormatted,
index: number,
): Promise<void> {
const canDeleteAll = this.conversation && this.conversation.candeletemessagesforallusers;
const langKey = message.pending || canDeleteAll || this.isSelf ? 'core.areyousure' :
'addon.messages.deletemessageconfirmation';
@ -1099,7 +1100,7 @@ export class AddonMessagesDiscussionPage implements OnInit, OnDestroy, AfterView
*/
scrollToFirstUnreadMessage(): void {
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]);
}

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 "byee" in the app
# TODO Fix this test in all Moodle versions
Scenario: User profile: send message, add/remove contact
Given I entered the app as "teacher1"
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 { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { ModalController, Network, NgZone } from '@singletons';
import { ModalController, NgZone } from '@singletons';
import { Subscription } from 'rxjs';
import { AddonModChat, AddonModChatUser } from '../../services/chat';
@ -42,7 +42,7 @@ export class AddonModChatUsersModalComponent implements OnInit, OnDestroy {
constructor() {
this.isOnline = CoreNetwork.isOnline();
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.
NgZone.run(() => {
this.isOnline = CoreNetwork.isOnline();

View File

@ -81,27 +81,10 @@
</ion-badge>
</div>
<ion-item *ngIf="!message.special" class="ion-text-wrap addon-message"
[class.addon-message-mine]="message.userid == currentUserId"
[class.addon-message-not-mine]="message.userid != currentUserId" [class.addon-message-no-user]="!message.showUserData"
[@coreSlideInOut]="message.userid == currentUserId ? '' : 'fromLeft'">
<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>
<core-message *ngIf="!message.special" [message]="message" [user]="message" [text]="message.message"
[time]="message.timestamp * 1000" (afterRender)="last && scrollToBottom()" contextLevel="module" [instanceId]="cmId"
[courseId]="courseId">
</core-message>
</ng-container>
</ion-list>

View File

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

View File

@ -75,26 +75,9 @@
</ion-badge>
</div>
<ion-item *ngIf="!message.special" class="ion-text-wrap addon-message"
[class.addon-message-mine]="message.userid == currentUserId"
[class.addon-message-not-mine]="message.userid != currentUserId" [class.addon-message-no-user]="!message.showUserData">
<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>
<core-message *ngIf="!message.special" [message]="message" [user]="message" [text]="message.message"
[time]="message.timestamp * 1000" contextLevel="module" [instanceId]="cmId" [courseId]="courseId">
</core-message>
</ng-container>
</ion-list>
</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 { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { Network, NgZone, Translate } from '@singletons';
import { NgZone, Translate } from '@singletons';
import { CoreEvents } from '@singletons/events';
import { Subscription } from 'rxjs';
import {
@ -80,7 +80,7 @@ export class AddonModFeedbackFormPage implements OnInit, OnDestroy, CanLeave {
this.currentSite = CoreSites.getRequiredCurrentSite();
// 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.
NgZone.run(() => {
this.offline = !CoreNetwork.isOnline();

View File

@ -30,7 +30,7 @@ import { CoreScreen } from '@services/screen';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { Network, NgZone, Translate } from '@singletons';
import { NgZone, Translate } from '@singletons';
import { CoreArray } from '@singletons/array';
import { CoreDom } from '@singletons/dom';
import { CoreEventObserver, CoreEvents } from '@singletons/events';
@ -166,7 +166,7 @@ export class AddonModForumDiscussionPage implements OnInit, AfterViewInit, OnDes
this.isOnline = CoreNetwork.isOnline();
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.
NgZone.run(() => {
this.isOnline = CoreNetwork.isOnline();

View File

@ -27,8 +27,9 @@ Feature: Test basic usage of forum activity in app
Scenario: Create new discussion
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
And I set the field "Subject" to "My happy subject" in the app
And I set the field "Message" to "An awesome message" in the app
And I set the following fields to these values in the app:
| Subject | My happy subject |
| Message | An awesome message |
And I press "Post to forum" 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
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
And I set the field "Subject" to "DiscussionSubject" in the app
And I set the field "Message" to "DiscussionMessage" in the app
And I set the following fields to these values in the app:
| Subject | DiscussionSubject |
| Message | DiscussionMessage |
And I press "Post to forum" in the app
And I press "DiscussionSubject" 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)
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
And I set the field "Subject" to "starred subject" in the app
And I set the field "Message" to "starred message" in the app
And I set the following fields to these values in the app:
| Subject | starred subject |
| Message | starred message |
And I press "Post to forum" 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 field "Message" to "normal message" in the app
And I set the following fields to these values in the app:
| Subject | normal subject |
| Message | normal message |
And I press "Post to forum" in the app
And I press "starred subject" 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)
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
And I set the field "Subject" to "Auto-test star" in the app
And I set the field "Message" to "Auto-test star message" in the app
And I set the following fields to these values in the app:
| Subject | Auto-test star |
| Message | Auto-test star message |
And I press "Post to forum" 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 field "Message" to "Auto-test pin message" in the app
And I set the following fields to these values in the app:
| Subject | Auto-test pin |
| Message | Auto-test pin message |
And I press "Post to forum" 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 field "Message" to "Auto-test plain message" in the app
And I set the following fields to these values in the app:
| Subject | Auto-test plain |
| Message | Auto-test plain message |
And I press "Post to forum" in the app
And I press "Display options" near "Auto-test star" 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
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
And I set the field "Subject" to "Auto-test" in the app
And I set the field "Message" to "Auto-test message" in the app
And I set the following fields to these values in the app:
| Subject | Auto-test |
| Message | Auto-test message |
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 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
When I switch offline mode to "true"
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 field "Message" to "Auto-test message" in the app
And I set the following fields to these values in the app:
| Subject | Auto-test |
| Message | Auto-test message |
And I press "Post to forum" 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
@ -169,8 +178,9 @@ Feature: Test basic usage of forum activity in app
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
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 field "Message" to "Auto-test message" in the app
And I set the following fields to these values in the app:
| Subject | Auto-test |
| Message | Auto-test message |
And I press "Post to forum" 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)
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
And I set the field "Subject" to "Auto-test" in the app
And I set the field "Message" to "Auto-test message" in the app
And I set the following fields to these values in the app:
| Subject | Auto-test |
| Message | Auto-test message |
And I press "Post to forum" 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
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
And I set the field "Subject" to "Auto-test" in the app
And I set the field "Message" to "Auto-test message" in the app
And I set the following fields to these values in the app:
| Subject | Auto-test |
| Message | Auto-test message |
And I press "Post to forum" in the app
And I press "Auto-test" 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
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
And I set the field "Subject" to "DiscussionSubject" in the app
And I set the field "Message" to "DiscussionMessage" in the app
And I set the following fields to these values in the app:
| Subject | DiscussionSubject |
| Message | DiscussionMessage |
And I press "Post to forum" in the app
And I press the back button 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
When I switch offline mode to "true"
And I press "Add discussion topic" in the app
And I set the field "Subject" to "DiscussionSubject" in the app
And I set the field "Message" to "DiscussionMessage" in the app
And I set the following fields to these values in the app:
| Subject | DiscussionSubject |
| Message | DiscussionMessage |
And I press "Post to forum" in the app
Then I should find "DiscussionSubject" 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
When I switch offline mode to "true"
And I press "Add discussion topic" in the app
And I set the field "Subject" to "DiscussionSubject" in the app
And I set the field "Message" to "DiscussionMessage" in the app
And I set the following fields to these values in the app:
| Subject | DiscussionSubject |
| Message | DiscussionMessage |
And I press "Post to forum" in the app
Then I should find "DiscussionSubject" 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
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
And I set the field "Subject" to "DiscussionSubject 1" in the app
And I set the field "Message" to "DiscussionMessage 1" in the app
And I set the following fields to these values in the app:
| Subject | DiscussionSubject 1 |
| Message | DiscussionMessage 1 |
And I press "Post to forum" 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
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 field "Message" to "DiscussionMessage 2" in the app
And I set the following fields to these values in the app:
| Subject | DiscussionSubject 2 |
| Message | DiscussionMessage 2 |
And I press "Post to forum" in the app
Then I should find "DiscussionSubject 1" 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
And I press "Add discussion topic" in the app
And I switch offline mode to "true"
And I set the field "Subject" to "Offline discussion 1" in the app
And I set the field "Message" to "Offline discussion 1 message" in the app
And I set the following fields to these values in the app:
| Subject | Offline discussion 1 |
| Message | Offline discussion 1 message |
And I press "Post to forum" 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 field "Message" to "Offline discussion 2 message" in the app
And I set the following fields to these values in the app:
| Subject | Offline discussion 2 |
| Message | Offline discussion 2 message |
And I press "Post to forum" 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 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 field "Message" to "Offline discussion 3 message" in the app
And I set the following fields to these values in the app:
| Subject | Offline discussion 3 |
| Message | Offline discussion 3 message |
And I press "Post to forum" in the app
Then I should find "Not sent" in the app
And I should find "Offline discussion 1" in the app
@ -197,20 +200,23 @@ Feature: Test forum navigation
# Offline
When I press "Add discussion topic" in the app
And I switch offline mode to "true"
And I set the field "Subject" to "Offline discussion 1" in the app
And I set the field "Message" to "Offline discussion 1 message" in the app
And I set the following fields to these values in the app:
| Subject | Offline discussion 1 |
| Message | Offline discussion 1 message |
And I press "Post to forum" 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 field "Message" to "Offline discussion 2 message" in the app
And I set the following fields to these values in the app:
| Subject | Offline discussion 2 |
| Message | Offline discussion 2 message |
And I press "Post to forum" 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 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 field "Message" to "Offline discussion 3 message" in the app
And I set the following fields to these values in the app:
| Subject | Offline discussion 3 |
| Message | Offline discussion 3 message |
And I press "Post to forum" in the app
Then I should find "Not sent" in the app
And I should find "Offline discussion 1" in the app

View File

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

View File

@ -72,8 +72,9 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
loadMoreError = false;
loadingMessage: string;
promisedEntries: CorePromisedValue<AddonModGlossaryEntriesManager>;
hasOfflineRatings = false;
protected hasOfflineEntries = false;
protected hasOfflineRatings = false;
protected syncEventName = AddonModGlossarySyncProvider.AUTO_SYNCED;
protected addEntryObserver?: CoreEventObserver;
protected fetchedEntriesCanLoadMore = false;
@ -128,7 +129,10 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
this.promisedEntries.resolve(new AddonModGlossaryEntriesManager(source, this));
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.
@ -146,12 +150,14 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
if (this.glossary && data.component == 'mod_glossary' && data.ratingArea == 'entry' && data.contextLevel == 'module'
&& data.instanceId == this.glossary.coursemodule) {
this.hasOfflineRatings = true;
this.hasOffline = true;
}
});
this.ratingSyncObserver = CoreEvents.on(CoreRatingSyncProvider.SYNCED_EVENT, (data) => {
if (this.glossary && data.component == 'mod_glossary' && data.ratingArea == 'entry' && data.contextLevel == 'module'
&& data.instanceId == this.glossary.coursemodule) {
this.hasOfflineRatings = false;
this.hasOffline = this.hasOfflineEntries;
}
});
}
@ -198,6 +204,7 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
]);
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
And I scroll to "Acerola" in the app
And I press "Search" in the app
And I set the field "Search" to "melon" in the app
And I press "Search" "button" near "Clear search" in the app
And I set the field "Search" to "something" 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
And I should find "Watermelon" 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 "Add a new entry" in the app
And I switch offline mode to "true"
And I set the field "Concept" to "Tomato" in the app
And I set the field "Definition" to "Tomato is a fruit" in the app
And I set the following fields to these values in the app:
| Concept | Tomato |
| Definition | Tomato is a fruit |
And I press "Save" in the app
And I press "Add a new entry" in the app
And I set the field "Concept" to "Cashew" in the app
And I set the field "Definition" to "Cashew is a fruit" in the app
And I set the following fields to these values in the app:
| Concept | Cashew |
| Definition | Cashew is a fruit |
And I press "Save" in the app
Then I should find "Entries to be synced" in the app
And I should find "Tomato" in the app
@ -248,8 +254,12 @@ Feature: Test glossary navigation
# Search
When I press "Search" in the app
And I set the field "Search" to "melon" in the app
And I press "Search" "button" near "Clear search" in the app
And I set the field "Search" to "something" 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
And I should find "Watermelon" 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
And I press "Add a new entry" in the app
And I switch offline mode to "true"
And I set the field "Concept" to "Tomato" in the app
And I set the field "Definition" to "Tomato is a fruit" in the app
And I set the following fields to these values in the app:
| Concept | Tomato |
| Definition | Tomato is a fruit |
And I press "Save" in the app
And I set the field "Concept" to "Cashew" in the app
And I set the field "Definition" to "Cashew is a fruit" in the app
And I set the following fields to these values in the app:
| Concept | Cashew |
| Definition | Cashew is a fruit |
And I press "Save" in the app
Then I should find "Entries to be synced" 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 { CoreTextUtils } from '@services/utils/text';
import { CoreUtils, OpenFileAction } from '@services/utils/utils';
import { Network, NgZone, Translate } from '@singletons';
import { NgZone, Translate } from '@singletons';
import { Subscription } from 'rxjs';
import {
AddonModResource,
@ -83,7 +83,7 @@ export class AddonModResourceIndexComponent extends CoreCourseModuleMainResource
this.isOnline = CoreNetwork.isOnline();
// 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.
NgZone.run(() => {
this.isOnline = CoreNetwork.isOnline();

View File

@ -50,14 +50,12 @@
<!-- Subquestion -->
<ng-container *ngIf="question.parent !== 0">
<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-label id="addon-mod_survey-{{question.id}}">
<span [core-mark-required]="question.required">
<strong>{{question.num}}.</strong> {{ question.text }}
</span>
</ion-label>
<span id="addon-mod_survey-{{question.name}}" [core-mark-required]="question.required">
<strong>{{question.num}}.</strong> {{ question.text }}
</span>
</ion-col>
<!-- Tablet view: radio buttons -->
@ -69,8 +67,8 @@
</ion-col>
<ion-col class="ion-hide-md-up" size="5">
<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"
[interfaceOptions]="{header: question.text}">
[attr.aria-labelledby]="'addon-mod_survey-'+question.name" interface="action-sheet"
[name]="question.name" [interfaceOptions]="{header: question.text}">
<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">
{{option}}
@ -85,17 +83,18 @@
<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-col size="7">
<ion-label id="addon-mod_survey-{{question.id}}">
<span [core-mark-required]="question.required">
<strong>{{question.num}}.</strong> {{ question.text }}
</span>
</ion-label>
<span id="addon-mod_survey-{{question.name}}" [core-mark-required]="question.required">
<strong>{{question.num}}.</strong> {{ question.text }}
</span>
</ion-col>
<ion-col size="5">
<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}">
<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}}
</ion-select-option>
</ion-select>
@ -103,13 +102,13 @@
</ion-row>
<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">
<strong>{{question.num}}.</strong> {{ question.text }}
</span>
</ion-label>
<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-item>
</ng-container>

View File

@ -68,7 +68,6 @@ export class AddonModSurveyHelperProvider {
formatQuestions(questions: AddonModSurveyQuestion[]): AddonModSurveyQuestionFormatted[] {
const strIPreferThat = Translate.instant('addon.mod_survey.ipreferthat');
const strIFoundThat = Translate.instant('addon.mod_survey.ifoundthat');
const strChoose = Translate.instant('core.choose');
const formatted: AddonModSurveyQuestionFormatted[] = [];
const parents = this.getParentQuestions(questions);
@ -112,9 +111,6 @@ export class AddonModSurveyHelperProvider {
// It's a single question.
q1.name = 'q' + q1.id;
q1.num = num++;
if (q1.type > 0) { // Add "choose" option since this question is not required.
q1.optionsArray.unshift(strChoose);
}
}
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 { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { Network, Translate, NgZone } from '@singletons';
import { Translate, NgZone } from '@singletons';
import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { CoreText } from '@singletons/text';
import { Subscription } from 'rxjs';
@ -119,7 +119,7 @@ export class AddonModWikiIndexComponent extends CoreCourseModuleMainActivityComp
this.isOnline = CoreNetwork.isOnline();
// 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.
NgZone.run(() => {
this.isOnline = CoreNetwork.isOnline();

View File

@ -19,7 +19,8 @@ import { BackButtonEvent, ScrollDetail } from '@ionic/core';
import { CoreLang } from '@services/lang';
import { CoreLoginHelper } from '@features/login/services/login-helper';
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 { CoreSites } from '@services/sites';
import { CoreNavigator } from '@services/navigator';
@ -32,7 +33,6 @@ import { CoreConstants } from '@/core/constants';
import { CoreSitePlugins } from '@features/siteplugins/services/siteplugins';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreDom } from '@singletons/dom';
import { CoreNetwork } from '@services/network';
const MOODLE_VERSION_PREFIX = 'version-';
const MOODLEAPP_VERSION_PREFIX = 'moodleapp-';
@ -308,7 +308,7 @@ export class AppComponent implements OnInit, AfterViewInit {
await Platform.ready();
// 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.
NgZone.run(() => {
const isOnline = CoreNetwork.isOnline();

View File

@ -36,7 +36,7 @@ export class CoreAnimations {
animate(300, keyframes([
style({ opacity: 0, transform: 'translateX(-100%)', offset: 0 }),
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.
@ -44,7 +44,7 @@ export class CoreAnimations {
animate(300, keyframes([
style({ opacity: 1, transform: 'translateX(0)', offset: 0 }),
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.
@ -52,7 +52,7 @@ export class CoreAnimations {
animate(300, keyframes([
style({ opacity: 0, transform: 'translateX(100%)', offset: 0 }),
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.
@ -60,7 +60,7 @@ export class CoreAnimations {
animate(300, keyframes([
style({ opacity: 1, transform: 'translateX(0)', offset: 0 }),
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 { CoreSwipeSlidesComponent } from './swipe-slides/swipe-slides';
import { CoreSwipeNavigationTourComponent } from './swipe-navigation-tour/swipe-navigation-tour';
import { CoreMessageComponent } from './message/message';
@NgModule({
declarations: [
@ -84,6 +85,7 @@ import { CoreSwipeNavigationTourComponent } from './swipe-navigation-tour/swipe-
CoreLoadingComponent,
CoreLocalFileComponent,
CoreMarkRequiredComponent,
CoreMessageComponent,
CoreModIconComponent,
CoreNavBarButtonsComponent,
CoreNavigationBarComponent,
@ -134,6 +136,7 @@ import { CoreSwipeNavigationTourComponent } from './swipe-navigation-tour/swipe-
CoreLoadingComponent,
CoreLocalFileComponent,
CoreMarkRequiredComponent,
CoreMessageComponent,
CoreModIconComponent,
CoreNavBarButtonsComponent,
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 CoreUserWithAvatar = CoreUserBasicData & {
export type CoreUserWithAvatar = CoreUserBasicData & {
userpictureurl?: string;
userprofileimageurl?: string;
profileimageurlsmall?: string;

View File

@ -45,71 +45,20 @@
{{ comment.timecreated * 1000 | coreFormatDate: "strftimedayshort" }}
</p>
<ion-item class="ion-text-wrap addon-message" [class.addon-message-mine]="comment.userid == currentUserId"
[class.addon-message-not-mine]="comment.userid != currentUserId" [class.addon-message-no-user]="!comment.showUserData"
[@coreSlideInOut]="comment.userid == currentUserId ? '' : 'fromLeft'">
<ion-label>
<!-- 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>
<core-message [message]="comment" [text]="comment.content" [time]="comment.timecreated * 1000" [user]="comment"
[showDelete]="showDelete" [contextLevel]="contextLevel" [instanceId]="instanceId" [courseId]="courseId"
(onDeleteMessage)="deleteComment(comment)" (onUndoDeleteMessage)="undoDeleteComment(comment)">
</core-message>
</ng-container>
<ion-item *ngIf="offlineComment" class="ion-text-wrap addon-message addon-message-mine">
<ion-label>
<!-- User data. -->
<p class="ion-text-center">
<ion-icon name="fas-exclamation-triangle" aria-hidden="true"></ion-icon>
{{ 'core.thereisdatatosync' | translate:{$a: 'core.comments.comments' | translate | lowercase } }}
</p>
<div class="addon-message-text">
<core-format-text [text]="offlineComment.content" [contextLevel]="contextLevel" [contextInstanceId]="instanceId"
[courseId]="courseId">
</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-badge class="ion-text-wrap" color="info" *ngIf="hasOffline">
<ion-icon name="fas-exclamation-triangle" aria-hidden="true"></ion-icon>
{{ 'core.thereisdatatosync' | translate:{$a: 'core.comments.comments' | translate | lowercase } }}
</ion-badge>
<core-message *ngIf="hasOffline && offlineComment" [message]="offlineComment" [text]="offlineComment.content"
[user]="offlineComment" [showDelete]="showDelete" [contextLevel]="contextLevel" [instanceId]="instanceId"
[courseId]="courseId" (onDeleteMessage)="deleteComment(offlineComment)">
</core-message>
</ion-list>
</core-loading>

View File

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

View File

@ -1 +1,5 @@
@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 { CoreTextUtils } from '@services/utils/text';
import { CoreUtils } from '@services/utils/utils';
import { ModalController, Network, NgZone } from '@singletons';
import { ModalController, NgZone } from '@singletons';
import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { Subscription } from 'rxjs';
@ -82,7 +82,7 @@ export class CoreCourseModuleSummaryComponent implements OnInit, OnDestroy {
this.isOnline = CoreNetwork.isOnline();
// 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.
NgZone.run(() => {
this.isOnline = CoreNetwork.isOnline();

View File

@ -10,20 +10,23 @@ Feature: Test basic usage of courses in app
| teacher1 | Teacher | teacher | teacher1@example.com |
| student1 | Student | student | student1@example.com |
And the following "courses" exist:
| fullname | shortname | category |
| Course 1 | C1 | 0 |
| Course 2 | C2 | 0 |
| Course 3 | C3 | 0 |
| Course 4 | C4 | 0 |
| fullname | shortname | category | visible |
| Course 1 | C1 | 0 | 1 |
| Course 2 | C2 | 0 | 1 |
| Course 3 | C3 | 0 | 1 |
| Course 4 | C4 | 0 | 1 |
| Hidden course | CH | 0 | 0 |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
| teacher1 | C2 | editingteacher |
| teacher1 | C3 | editingteacher |
| teacher1 | C4 | editingteacher |
| teacher1 | CH | editingteacher |
| student1 | C1 | student |
| student1 | C2 | student |
| student1 | C3 | student |
| student1 | CH | student |
And the following "activities" exist:
| activity | name | intro | course | idnumber | option |
| 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 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
Scenario: See my courses
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 { InAppBrowserMock } from './services/inappbrowser';
import { MediaCaptureMock } from './services/media-capture';
import { NetworkMock } from './services/network';
import { ZipMock } from './services/zip';
import { CoreNetworkService } from '@services/network';
/**
* This module handles the emulation of Cordova plugins in browser and desktop.
@ -152,11 +152,7 @@ import { ZipMock } from './services/zip';
deps: [Platform],
useFactory: (platform: Platform): MediaCapture => platform.is('cordova') ? new MediaCapture() : new MediaCaptureMock(),
},
{
provide: Network,
deps: [Platform],
useFactory: (platform: Platform): Network => platform.is('cordova') ? new Network() : new NetworkMock(),
},
CoreNetworkService,
Push,
QRScanner,
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
Then I should find "Acceptance test site" in the app
When I set the field "Username" to "student1" in the app
And I set the field "Password" to "student1" in the app
When I set the following fields to these values in the app:
| Username | student1 |
| Password | student1 |
And I press "Log in" near "Forgotten your username or password?" in the app
Then I should find "Acceptance test site" 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.
*
* @param notification Notification.
* @param data Notification data.
* @return Promise resolved when done.
*/
async notificationClicked(data: CorePushNotificationsNotificationBasicData): Promise<void> {

View File

@ -16,7 +16,7 @@ import { CoreApp } from '@services/app';
import { Component, OnDestroy } from '@angular/core';
import { CoreConstants } from '@/core/constants';
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 { CoreFile } from '@services/file';
import { CoreSites } from '@services/sites';
@ -82,7 +82,6 @@ export class CoreSettingsDeviceInfoPage implements OnDestroy {
protected onlineObserver?: Subscription;
constructor() {
const appProvider = CoreApp.instance;
const sitesProvider = CoreSites.instance;
const device = Device.instance;
const translate = Translate.instance;
@ -112,10 +111,10 @@ export class CoreSettingsDeviceInfoPage implements OnDestroy {
if (CorePlatform.isMobile()) {
this.deviceInfo.deviceType = Platform.is('tablet') ? 'tablet' : 'phone';
if (appProvider.isAndroid()) {
if (CoreApp.isAndroid()) {
this.deviceInfo.deviceOs = 'android';
this.deviceOsTranslated = 'Android';
} else if (appProvider.isIOS()) {
} else if (CoreApp.isIOS()) {
this.deviceInfo.deviceOs = 'ios';
this.deviceOsTranslated = 'iOS';
} else {
@ -177,7 +176,7 @@ export class CoreSettingsDeviceInfoPage implements OnDestroy {
this.deviceInfo.siteVersion = currentSite?.getInfo()?.release;
// 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.
NgZone.run(() => {
this.deviceInfo.networkStatus = CoreNetwork.isOnline() ? 'online' : 'offline';

View File

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

View File

@ -13,11 +13,12 @@
// limitations under the License.
import { CoreCronDelegate } from '@services/cron';
import { Network, NgZone } from '@singletons';
import { NgZone } from '@singletons';
import { CoreNetwork } from '@services/network';
export default function(): void {
// 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.
NgZone.run(() => CoreCronDelegate.startNetworkHandlers());
});

View File

@ -31,7 +31,7 @@ import { CoreUtils, CoreUtilsOpenFileOptions } from '@services/utils/utils';
import { SQLiteDB } from '@classes/sqlitedb';
import { CoreError } from '@classes/errors/error';
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 {
APP_SCHEMA,
@ -150,7 +150,7 @@ export class CoreFilepoolProvider {
this.checkQueueProcessing();
// 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.
NgZone.run(() => this.checkQueueProcessing());
});

View File

@ -14,15 +14,56 @@
import { Injectable } from '@angular/core';
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' })
export class CoreNetworkService {
export class CoreNetworkService extends NetworkService {
type!: string;
protected connectObservable = new Subject<'connected'>();
protected disconnectObservable = new Subject<'disconnected'>();
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.
@ -31,6 +72,7 @@ export class CoreNetworkService {
*/
setForceOffline(value: boolean): void {
this.forceOffline = !!value;
this.fireObservable();
}
/**
@ -39,23 +81,77 @@ export class CoreNetworkService {
* @return Whether the app is online.
*/
isOnline(): boolean {
return this.online;
}
/**
* Returns whether we are online.
*
* @return Whether the app is online.
*/
checkOnline(): void {
if (this.forceOffline) {
return false;
this.online = false;
return;
}
if (!CorePlatform.isMobile()) {
return navigator.onLine;
this.online = navigator.onLine;
return;
}
let online = Network.type !== null && Network.type != Network.Connection.NONE &&
Network.type != Network.Connection.UNKNOWN;
let online = this.type !== null && this.type != this.Connection.NONE &&
this.type != this.Connection.UNKNOWN;
// Double check we are not online because we cannot rely 100% in Cordova APIs.
if (!online && navigator.onLine) {
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 { 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 { CoreUrl } from '@singletons/url';
import { CoreWindow } from '@singletons/window';
@ -76,7 +76,7 @@ export class CoreIframeUtilsProvider {
this.addOfflineWarning(element, src, isSubframe);
// 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.
NgZone.run(() => {
if (!this.checkOnlineFrameInOffline(element, isSubframe)) {

View File

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

View File

@ -12,9 +12,9 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { CorePromisedValue } from '@classes/promised-value';
import { CoreUtils } from '@services/utils/utils';
import { NgZone } from '@singletons';
import { TestsBehatBlocking } from './behat-blocking';
import { TestBehatElementLocator } from './behat-runtime';
// Containers that block containers behind them.
@ -82,7 +82,7 @@ export class TestsBehatDomUtils {
* @return Elements containing the given text with exact boolean.
*/
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))
.filter((element => this.isElementVisible(element, container)))
@ -104,7 +104,9 @@ export class TestsBehatDomUtils {
}
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;
}
@ -176,7 +178,8 @@ export class TestsBehatDomUtils {
protected static checkElementLabel(element: HTMLElement, text: string): boolean {
return element.title === 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);
};
}
/**
* 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.
*
* @param locator Element locator.
* @param container Container to search in.
* @param topContainer Container to search in.
* @return Found elements
*/
protected static findElementsBasedOnTextInContainer(
@ -377,7 +380,7 @@ export class TestsBehatDomUtils {
const withinElementsAncestors = this.getTopAncestors(withinElements);
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];
@ -395,7 +398,7 @@ export class TestsBehatDomUtils {
const nearElementsAncestors = this.getTopAncestors(nearElements);
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]);
@ -444,21 +447,23 @@ export class TestsBehatDomUtils {
element.scrollIntoView(false);
return new Promise<DOMRect>((resolve): void => {
requestAnimationFrame(() => {
const rect = element.getBoundingClientRect();
const promise = new CorePromisedValue<DOMRect>();
if (initialRect.y !== rect.y) {
setTimeout(() => {
resolve(rect);
}, 300);
requestAnimationFrame(() => {
const rect = element.getBoundingClientRect();
return;
}
if (initialRect.y !== rect.y) {
setTimeout(() => {
promise.resolve(rect);
}, 300);
resolve(rect);
});
return;
}
promise.resolve(rect);
});
return promise;
};
/**
@ -467,8 +472,8 @@ export class TestsBehatDomUtils {
* @param element Element to press.
*/
static async pressElement(element: HTMLElement): Promise<void> {
NgZone.run(async () => {
const blockKey = TestsBehatBlocking.block();
await NgZone.run(async () => {
const promise = new CorePromisedValue<void>();
// Events don't bubble up across Shadow DOM boundaries, and some buttons
// may not work without doing this.
@ -498,8 +503,10 @@ export class TestsBehatDomUtils {
element.dispatchEvent(new MouseEvent('mouseup', eventOptions));
element.click();
TestsBehatBlocking.unblock(blockKey);
promise.resolve();
}, 300);
return promise;
});
}
@ -509,22 +516,33 @@ export class TestsBehatDomUtils {
* @param element HTML to set.
* @param value Value to be set.
*/
static async setElementValue(element: HTMLElement, value: string): Promise<void> {
NgZone.run(async () => {
const blockKey = TestsBehatBlocking.block();
static async setElementValue(element: HTMLInputElement | HTMLElement, value: string): Promise<void> {
await NgZone.run(async () => {
const promise = new CorePromisedValue<void>();
// Functions to get/set value depending on field type.
let setValue = (text: string) => {
element.innerHTML = text;
};
let getValue = () => element.innerHTML;
const setValue = (text: string) => {
if (element.tagName === 'ION-SELECT' && 'value' in element) {
value = value.trim();
const optionValue = Array.from(element.querySelectorAll('ion-select-option'))
.find((option) => option.innerHTML.trim() === value);
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
setValue = (text: string) => {
if (optionValue) {
element.value = optionValue.value;
}
} else if ('value' in element) {
element.value = text;
};
getValue = () => element.value;
}
} 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.
let event: InputEvent;
@ -555,7 +573,9 @@ export class TestsBehatDomUtils {
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 { CoreConfig } from '@services/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.
@ -45,6 +55,10 @@ export class TestsBehatRuntime {
scrollTo: TestsBehatRuntime.scrollTo,
setField: TestsBehatRuntime.setField,
handleCustomURL: TestsBehatRuntime.handleCustomURL,
notificationClicked: TestsBehatRuntime.notificationClicked,
forceSyncExecution: TestsBehatRuntime.forceSyncExecution,
waitLoadingToFinish: TestsBehatRuntime.waitLoadingToFinish,
network: CoreNetwork.instance,
};
if (!options) {
@ -69,26 +83,69 @@ export class TestsBehatRuntime {
* @return OK if successful, or ERROR: followed by message.
*/
static async handleCustomURL(url: string): Promise<string> {
const blockKey = TestsBehatBlocking.block();
try {
await CoreCustomURLSchemes.handleCustomURL(url);
await NgZone.run(async () => {
await CoreCustomURLSchemes.handleCustomURL(url);
});
return 'OK';
} catch (error) {
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 {
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.
*
* @param button Type of button to press.
* @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);
// Find button
@ -120,7 +177,7 @@ export class TestsBehatRuntime {
}
// Click button
TestsBehatDomUtils.pressElement(foundButton);
await TestsBehatDomUtils.pressElement(foundButton);
return 'OK';
}
@ -140,7 +197,7 @@ export class TestsBehatRuntime {
return 'ERROR: Could not find backdrop';
}
if (backdrops.length > 1) {
return 'ERROR: Found too many backdrops';
return 'ERROR: Found too many backdrops ('+backdrops.length+')';
}
const backdrop = backdrops[0];
backdrop.click();
@ -274,7 +331,7 @@ export class TestsBehatRuntime {
* @param locator Element locator.
* @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);
try {
@ -284,7 +341,7 @@ export class TestsBehatRuntime {
return 'ERROR: No element matches locator to press.';
}
TestsBehatDomUtils.pressElement(found);
await TestsBehatDomUtils.pressElement(found);
return 'OK';
} catch (error) {
@ -304,7 +361,7 @@ export class TestsBehatRuntime {
titles = titles.filter((title) => TestsBehatDomUtils.isElementVisible(title, document.body));
if (titles.length > 1) {
return 'ERROR: Too many possible titles.';
return 'ERROR: Too many possible titles ('+titles.length+').';
} else if (!titles.length) {
return 'ERROR: No title found.';
} else {
@ -323,18 +380,18 @@ export class TestsBehatRuntime {
* @param value New value
* @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);
const found: HTMLElement | HTMLInputElement | HTMLTextAreaElement =TestsBehatDomUtils.findElementBasedOnText(
{ text: field, selector: 'input, textarea, [contenteditable="true"]' },
const found: HTMLElement | HTMLInputElement = TestsBehatDomUtils.findElementBasedOnText(
{ text: field, selector: 'input, textarea, [contenteditable="true"], ion-select' },
);
if (!found) {
return 'ERROR: No element matches field to set.';
}
TestsBehatDomUtils.setElementValue(found, value);
await TestsBehatDomUtils.setElementValue(found, value);
return 'OK';
}

View File

@ -19,11 +19,12 @@ import { Observable, Subject } from 'rxjs';
import { sep } from 'path';
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 { TranslatePipeStub } from './stubs/pipes/translate';
import { CoreExternalContentDirectiveStub } from './stubs/directives/core-external-content';
import { CoreNetwork } from '@services/network';
abstract class WrapperComponent<U> {
@ -37,7 +38,7 @@ let testBedInitialized = false;
const textUtils = new CoreTextUtilsProvider();
const DEFAULT_SERVICE_SINGLETON_MOCKS: [CoreSingletonProxy, Record<string, unknown>][] = [
[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>> {

View File

@ -27,148 +27,3 @@ ion-content {
font-weight: normal;
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);
--addon-messages-message-bg: var(--gray-800);
--addon-messages-message-activated-bg: var(--gray-700);
--addon-messages-message-note-text: var(--subdued-text-color);
--addon-messages-message-mine-bg: var(--gray-700);
--addon-messages-message-mine-activated-bg: var(--gray-600);
--addon-messages-discussion-badge: var(--primary);
--addon-messages-discussion-badge-text: var(--gray-100);
--core-messages-message-bg: var(--gray-800);
--core-messages-message-activated-bg: var(--gray-700);
--core-messages-message-note-text: var(--subdued-text-color);
--core-messages-message-mine-bg: var(--gray-700);
--core-messages-message-mine-activated-bg: var(--gray-600);
--core-messages-discussion-badge: var(--primary);
--core-messages-discussion-badge-text: var(--gray-100);
--addon-forum-border-color: var(--gray-500);
--addon-forum-highlight-color: var(--gray-200);

View File

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

View File

@ -1,9 +1,10 @@
This files describes API changes in the Moodle Mobile app,
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".
- --addon-messages-* CSS3 variables have been renamed to --core-messages-*
=== 4.0.0 ===