Merge pull request #3447 from dpalou/MOBILE-3784

Mobile 3784
main
Noel De Martin 2022-11-16 16:27:26 +01:00 committed by GitHub
commit 105f283559
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 265 additions and 58 deletions

View File

@ -1012,4 +1012,22 @@ class behat_app extends behat_app_helper {
return true;
}
/**
* View a specific month in the calendar in the app.
*
* @When /^I open the calendar for "(?P<month>\d+)" "(?P<year>\d+)" in the app$/
* @param int $month the month selected as a number
* @param int $year the four digit year
*/
public function i_open_the_calendar_for($month, $year) {
$options = json_encode([
'params' => [
'month' => $month,
'year' => $year,
],
]);
$this->zone_js("navigator.navigateToSitePath('/calendar/index', $options)");
}
}

View File

@ -1695,6 +1695,7 @@
"core.editor.underline": "atto_underline/pluginname",
"core.editor.unorderedlist": "atto_unorderedlist/pluginname",
"core.emptysplit": "local_moodlemobileapp",
"core.endingtime": "local_moodlemobileapp",
"core.endonesteptour": "tool_usertours",
"core.error": "moodle",
"core.errorchangecompletion": "local_moodlemobileapp",
@ -2304,6 +2305,7 @@
"core.sort": "moodle",
"core.sortby": "moodle",
"core.start": "local_moodlemobileapp",
"core.startingtime": "local_moodlemobileapp",
"core.storingfiles": "local_moodlemobileapp",
"core.strftimedate": "langconfig",
"core.strftimedatefullshort": "langconfig",

View File

@ -257,27 +257,9 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro
* Go to current month.
*/
async goToCurrentMonth(): Promise<void> {
const manager = this.manager;
const slides = this.slides;
if (!manager || !slides) {
return;
}
const currentMoment = moment();
const currentMonth = {
moment: moment(),
};
this.loaded = false;
try {
// Make sure the day is loaded.
await manager.getSource().loadItem(currentMonth);
slides.slideToItem(currentMonth);
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true);
} finally {
this.loaded = true;
}
await this.viewMonth(currentMoment.month() + 1, currentMoment.year());
}
/**
@ -319,6 +301,39 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro
});
}
/**
* View a certain month and year.
*
* @param month Month.
* @param year Year.
*/
async viewMonth(month: number, year: number): Promise<void> {
const manager = this.manager;
const slides = this.slides;
if (!manager || !slides) {
return;
}
this.loaded = false;
const item = {
moment: moment({
year,
month: month - 1,
}),
};
try {
// Make sure the day is loaded.
await manager.getSource().loadItem(item);
slides.slideToItem(item);
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true);
} finally {
this.loaded = true;
}
}
/**
* Component destroyed.
*/
@ -511,6 +526,7 @@ class AddonCalendarMonthSlidesItemsManagerSource extends CoreSwipeSlidesDynamicI
const weeks = result.weeks as AddonCalendarWeek[];
const currentDay = moment().date();
const currentTime = CoreTimeUtils.timestamp();
const dayMoment = moment(month.moment);
const preloadedMonth: PreloadedMonth = {
...month,
@ -523,7 +539,7 @@ class AddonCalendarMonthSlidesItemsManagerSource extends CoreSwipeSlidesDynamicI
await Promise.all(weeks.map(async (week) => {
await Promise.all(week.days.map(async (day) => {
day.periodName = CoreTimeUtils.userDate(
month.moment.unix() * 1000,
dayMoment.date(day.mday).unix() * 1000,
'core.strftimedaydate',
);
day.eventsFormated = day.eventsFormated || [];

View File

@ -31,7 +31,7 @@
<p class="item-heading" [core-mark-required]="true">{{ 'core.date' | translate }}</p>
</ion-label>
<ion-datetime formControlName="timestart" [placeholder]="'core.date' | translate" [displayFormat]="dateFormat"
[max]="maxDate" [min]="minDate" display-timezone="utc">
[max]="maxDate" [min]="minDate" [displayTimezone]="displayTimezone">
</ion-datetime>
<core-input-errors [control]="form.controls.timestart" [errorMessages]="errors"></core-input-errors>
</ion-item>
@ -156,7 +156,8 @@
<ion-item *ngIf="form.controls.duration.value === 1">
<ion-label position="stacked"></ion-label>
<ion-datetime formControlName="timedurationuntil" [max]="maxDate" [min]="minDate"
[placeholder]="'addon.calendar.durationuntil' | translate" [displayFormat]="dateFormat" display-timezone="utc">
[placeholder]="'addon.calendar.durationuntil' | translate" [displayFormat]="dateFormat"
[displayTimezone]="displayTimezone">
</ion-datetime>
</ion-item>
<ion-item>

View File

@ -45,6 +45,8 @@ import { CanLeave } from '@guards/can-leave';
import { CoreForms } from '@singletons/form';
import { CoreReminders, CoreRemindersService, CoreRemindersUnits } from '@features/reminders/services/reminders';
import { CoreRemindersSetReminderMenuComponent } from '@features/reminders/components/set-reminder-menu/set-reminder-menu';
import moment from 'moment-timezone';
import { CoreAppProvider } from '@services/app';
/**
* Page that displays a form to create/edit an event.
@ -77,6 +79,7 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy, CanLeave {
eventId?: number;
maxDate: string;
minDate: string;
displayTimezone?: string;
// Form variables.
form: FormGroup;
@ -108,6 +111,7 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy, CanLeave {
// Calculate format to use. ion-datetime doesn't support escaping characters ([]), so we remove them.
this.dateFormat = CoreTimeUtils.convertPHPToMoment(Translate.instant('core.strftimedatetimeshort'))
.replace(/[[\]]/g, '');
this.displayTimezone = CoreAppProvider.getForcedTimezone();
this.form = new FormGroup({});
@ -454,8 +458,8 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy, CanLeave {
async submit(): Promise<void> {
// Validate data.
const formData = this.form.value;
const timeStartDate = CoreTimeUtils.convertToTimestamp(formData.timestart, true);
const timeUntilDate = CoreTimeUtils.convertToTimestamp(formData.timedurationuntil, true);
const timeStartDate = moment(formData.timestart).unix();
const timeUntilDate = moment(formData.timedurationuntil).unix();
const timeDurationMinutes = parseInt(formData.timedurationminutes || '', 10);
let error: string | undefined;
@ -488,6 +492,7 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy, CanLeave {
description: {
text: formData.description || '',
format: 1,
itemid: 0, // Files not supported yet.
},
location: formData.location,
duration: formData.duration,

View File

@ -58,7 +58,7 @@
</h1>
</ion-label>
</ion-item>
<ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
<h2>{{ 'addon.calendar.when' | translate }}</h2>
<core-format-text [text]="event.formattedtime" [contextLevel]="event.contextLevel"

View File

@ -173,6 +173,10 @@ export class AddonCalendarIndexPage implements OnInit, OnDestroy {
this.filter.filtered = !!this.filter.courseId;
this.fetchData(true, false);
if (this.year !== undefined && this.month !== undefined && this.calendarComponent) {
this.calendarComponent.viewMonth(this.month, this.year);
}
});
const deepLinkManager = new CoreMainMenuDeepLinkManager();

View File

@ -253,6 +253,7 @@ export class AddonCalendarSyncProvider extends CoreSyncBaseProvider<AddonCalenda
description: {
text: event.description || '',
format: 1,
itemid: 0, // Files not supported yet.
},
},
); // Clone the object because it will be modified in the submit function.

View File

@ -345,19 +345,24 @@ export class AddonCalendarProvider {
siteId?: string,
): Promise<string> {
const getTimeHtml = (time: string, a11yLangKey: string): string =>
`<span aria-label="${Translate.instant(a11yLangKey, { $a: CoreTextUtils.cleanTags(time) })}">${time}</span>`;
const getStartTimeHtml = (time: string): string => getTimeHtml(time, 'core.startingtime');
const getEndTimeHtml = (time: string): string => getTimeHtml(time, 'core.endingtime');
const start = event.timestart * 1000;
const end = (event.timestart + event.timeduration) * 1000;
let time: string;
if (!event.timeduration) {
if (event.timeduration) {
if (moment(start).isSame(end, 'day')) {
// Event starts and ends the same day.
if (event.timeduration == CoreConstants.SECONDS_DAY) {
time = Translate.instant('addon.calendar.allday');
} else {
time = CoreTimeUtils.userDate(start, format) + ' <strong>&raquo;</strong> ' +
CoreTimeUtils.userDate(end, format);
time = getStartTimeHtml(CoreTimeUtils.userDate(start, format)) + ' <strong>&raquo;</strong> ' +
getEndTimeHtml(CoreTimeUtils.userDate(end, format));
}
} else {
@ -388,11 +393,12 @@ export class AddonCalendarProvider {
await Promise.all(promises);
return dayStart + timeStart + ' <strong>&raquo;</strong> ' + dayEnd + timeEnd;
return getStartTimeHtml(dayStart + timeStart) + ' <strong>&raquo;</strong> ' +
getEndTimeHtml(dayEnd + timeEnd);
}
} else {
// There is no time duration.
time = CoreTimeUtils.userDate(start, format);
time = getStartTimeHtml(CoreTimeUtils.userDate(start, format));
}
if (showTime) {
@ -2173,6 +2179,7 @@ export type AddonCalendarSubmitCreateUpdateFormDataWSParams = Omit<AddonCalendar
description?: {
text: string;
format: number;
itemid: number; // File area ID.
};
visible?: number;
instance?: number;

View File

@ -0,0 +1,64 @@
@core @core_calendar @app @javascript
Feature: Test creation of calendar events in app
In order to take advantage of all the calendar features while using the mobile app
As a student
I need basic to be able to create and edit calendar events in the app
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | teacher | teacher1@example.com |
| student1 | Student1 | student1 | 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 |
Scenario: Create user event as student from monthly view
Given I entered the app as "student1"
When I press "More" in the app
And I press "Calendar" in the app
And I press "New event" in the app
Then the field "Date" matches value "## now ##%d/%m/%y, %H:%M##" in the app
And I should not be able to press "Save" in the app
# Check that student can only create User events.
When I press "Type of event" in the app
Then I should not find "Cancel" in the app
And I should find "User" within "Type of event" "ion-item" in the app
# Create the event.
When I set the field "Event title" to "User Event 01" in the app
And I set the field "Date" to "2025-04-11T09:00+08:00" in the app
And I press "Without duration" in the app
And I set the field "Description" to "This is User Event 01 description." in the app
And I set the field "Location" to "Barcelona" in the app
And I press "Save" in the app
Then I should find "Calendar events" in the app
# Verify that event was created right.
When I open the calendar for "4" "2025" in the app
And I press "Friday, 11 April 2025" in the app
Then I should find "User Event 01" in the app
When I press "User Event 01" in the app
Then I should find "Friday, 11 April" in the app
And I should find "Starting time: 9:00 AM" in the app
And I should find "User event" within "Event type" "ion-item" in the app
And I should find "This is User Event 01 description." in the app
And I should find "Barcelona" in the app
But I should not find "Ending time" in the app
When I press "Display options" in the app
Then I should find "Edit" in the app
And I should find "Delete" in the app
When I close the popup in the app
And I press "Barcelona" in the app
And I press "OK" in the app
Then the app should have opened a browser tab with url "google.com"
# @todo: Add more Scenarios to test teacher, different values, and creating events from other views (e.g. day view).

View File

@ -1,7 +1,7 @@
<span *ngIf="inputMode && form" [formGroup]="form">
<span *ngIf="editMode" [core-mark-required]="field.required" class="core-mark-required"></span>
<ion-datetime [formControlName]="'f_'+field.id" [placeholder]="'core.date' | translate" [max]="maxDate" [min]="minDate"
[disabled]="searchMode && !searchFields!['f_'+field.id+'_z']" [displayFormat]="format" display-timezone="utc">
[disabled]="searchMode && !searchFields!['f_'+field.id+'_z']" [displayFormat]="format" [displayTimezone]="displayTimezone">
</ion-datetime>
<core-input-errors *ngIf="error && editMode" [control]="form.controls['f_'+field.id]" [errorText]="error"></core-input-errors>

View File

@ -13,6 +13,7 @@
// limitations under the License.
import { Component } from '@angular/core';
import { CoreAppProvider } from '@services/app';
import { CoreTimeUtils } from '@services/utils/time';
import { Translate } from '@singletons';
import moment, { Moment } from 'moment-timezone';
@ -31,6 +32,7 @@ export class AddonModDataFieldDateComponent extends AddonModDataFieldPluginBaseC
displayDate?: number;
maxDate?: string;
minDate?: string;
displayTimezone?: string;
/**
* @inheritdoc
@ -52,6 +54,7 @@ export class AddonModDataFieldDateComponent extends AddonModDataFieldPluginBaseC
));
this.maxDate = CoreTimeUtils.getDatetimeDefaultMax();
this.minDate = CoreTimeUtils.getDatetimeDefaultMin();
this.displayTimezone = CoreAppProvider.getForcedTimezone();
if (this.searchMode) {
this.addControl('f_' + this.field.id + '_z');

View File

@ -12,7 +12,7 @@
<span [core-mark-required]="required">{{ field.name }}</span>
</ion-label>
<ion-datetime [formControlName]="modelName" [placeholder]="'core.choosedots' | translate" [displayFormat]="format" [max]="max"
[min]="min" [monthNames]="monthNames">
[min]="min" [monthNames]="monthNames" [displayTimezone]="displayTimezone">
</ion-datetime>
<core-input-errors [control]="form.controls[modelName]"></core-input-errors>
</ion-item>

View File

@ -22,6 +22,7 @@ import { CoreUserProfileField } from '@features/user/services/user';
import { Translate } from '@singletons';
import { CoreUserProfileFieldBaseComponent } from '@features/user/classes/base-profilefield-component';
import { CoreLang } from '@services/lang';
import { CoreAppProvider } from '@services/app';
/**
* Directive to render a datetime user profile field.
@ -37,6 +38,7 @@ export class AddonUserProfileFieldDatetimeComponent extends CoreUserProfileField
max?: string;
valueNumber = 0;
monthNames?: string[];
displayTimezone?: string;
/**
* Init the data when the field is meant to be displayed without editing.
@ -56,6 +58,7 @@ export class AddonUserProfileFieldDatetimeComponent extends CoreUserProfileField
super.initForEdit(field);
this.monthNames = CoreLang.getMonthNames();
this.displayTimezone = CoreAppProvider.getForcedTimezone();
// Check if it's only date or it has time too.
const hasTime = CoreUtils.isTrueOrOne(field.param3);

View File

@ -18,9 +18,9 @@ import { AuthEmailSignupProfileField } from '@features/login/services/login-help
import { CoreUserProfileField } from '@features/user/services/user';
import { CoreUserProfileFieldHandler, CoreUserProfileFieldHandlerData } from '@features/user/services/user-profile-field-delegate';
import { CoreFormFields } from '@singletons/form';
import { CoreTimeUtils } from '@services/utils/time';
import { makeSingleton } from '@singletons';
import { AddonUserProfileFieldDatetimeComponent } from '../../component/datetime';
import moment from 'moment-timezone';
/**
* Datetime user profile field handlers.
@ -61,7 +61,7 @@ export class AddonUserProfileFieldDatetimeHandlerService implements CoreUserProf
return {
type: 'datetime',
name: 'profile_field_' + field.shortname,
value: CoreTimeUtils.convertToTimestamp(<string> formValues[name]),
value: moment(<string> formValues[name]).unix(),
};
}
}

View File

@ -18,6 +18,7 @@ import {
import { CoreSwipeSlidesItemsManager } from '@classes/items-management/swipe-slides-items-manager';
import { IonContent, IonSlides } from '@ionic/angular';
import { CoreDomUtils, VerticalPoint } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { CoreDom } from '@singletons/dom';
import { CoreEventObserver } from '@singletons/events';
import { CoreMath } from '@singletons/math';
@ -43,6 +44,7 @@ export class CoreSwipeSlidesComponent<Item = unknown> implements OnChanges, OnDe
protected hostElement: HTMLElement;
protected unsubscribe?: () => void;
protected resizeListener: CoreEventObserver;
protected updateSlidesPromise?: Promise<void>;
constructor(
elementRef: ElementRef<HTMLElement>,
@ -51,7 +53,7 @@ export class CoreSwipeSlidesComponent<Item = unknown> implements OnChanges, OnDe
this.hostElement = elementRef.nativeElement;
this.resizeListener = CoreDom.onWindowResize(() => {
this.slides?.update();
this.updateSlidesComponent();
});
}
@ -118,7 +120,22 @@ export class CoreSwipeSlidesComponent<Item = unknown> implements OnChanges, OnDe
* @param speed Animation speed.
* @param runCallbacks Whether to run callbacks.
*/
slideToIndex(index: number, speed?: number, runCallbacks?: boolean): void {
async slideToIndex(index: number, speed?: number, runCallbacks?: boolean): Promise<void> {
// If slides are being updated, wait for the update to finish.
await this.updateSlidesPromise;
const slides = this.slides;
if (!slides) {
return;
}
// Verify that the number of slides matches the number of items.
const slidesLength = await slides.length();
if (slidesLength !== this.items.length) {
// Number doesn't match, do a new update to try to match them.
await this.updateSlidesComponent();
}
this.slides?.slideTo(index, speed, runCallbacks);
}
@ -132,7 +149,7 @@ export class CoreSwipeSlidesComponent<Item = unknown> implements OnChanges, OnDe
slideToItem(item: Item, speed?: number, runCallbacks?: boolean): void {
const index = this.manager?.getSource().getItemIndex(item) ?? -1;
if (index != -1) {
this.slides?.slideTo(index, speed, runCallbacks);
this.slideToIndex(index, speed, runCallbacks);
}
}
@ -158,10 +175,14 @@ export class CoreSwipeSlidesComponent<Item = unknown> implements OnChanges, OnDe
/**
* Called when items list has been updated.
*
* @param items New items.
*/
protected onItemsUpdated(): void {
protected async onItemsUpdated(): Promise<void> {
// Wait for slides to be added in DOM.
await CoreUtils.nextTick();
// Update the slides component so the slides list reflects the new items.
await this.updateSlidesComponent();
const currentItem = this.manager?.getSelectedItem();
if (!currentItem || !this.manager) {
@ -249,6 +270,24 @@ export class CoreSwipeSlidesComponent<Item = unknown> implements OnChanges, OnDe
};
}
/**
* Update slides component.
*/
protected async updateSlidesComponent(): Promise<void> {
if (!this.slides) {
return;
}
const promise = this.slides.update();
this.updateSlidesPromise = promise;
await promise;
if (this.updateSlidesPromise === promise) {
delete this.updateSlidesPromise;
}
}
/**
* @inheritdoc
*/

View File

@ -97,6 +97,7 @@
"downloading": "Downloading",
"edit": "Edit",
"emptysplit": "This page will appear blank if the left panel is empty or is loading.",
"endingtime": "Ending time: {{$a}}",
"endonesteptour": "Got it",
"error": "Error",
"errorchangecompletion": "An error occurred while changing the completion status. Please try again.",
@ -294,6 +295,7 @@
"sort": "Sort",
"sortby": "Sort by",
"start": "Start",
"startingtime": "Starting time: {{$a}}",
"storingfiles": "Storing files",
"strftimedate": "%d %B %Y",
"strftimedatefullshort": "%d/%m/%y",

View File

@ -70,6 +70,18 @@ export class CoreAppProvider {
return !!navigator.webdriver;
}
/**
* Returns the forced timezone to use. Timezone is forced for automated tests.
*
* @return Timezone. Undefined to use the user's timezone.
*/
static getForcedTimezone(): string | undefined {
if (CoreAppProvider.isAutomated()) {
// Use the same timezone forced for LMS in tests.
return 'Australia/Perth';
}
}
/**
* Initialize database.
*/

View File

@ -261,23 +261,14 @@ export class CoreTimeUtilsProvider {
/**
* Convert a text into user timezone timestamp.
*
* @todo The `applyOffset` argument is only used as a workaround, it should be removed once
* MOBILE-3784 is resolved.
*
* @param date To convert to timestamp.
* @param applyOffset Whether to apply offset to date or not.
* @return Converted timestamp.
* @deprecated since 4.1. Use moment(date).unix() instead.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
convertToTimestamp(date: string, applyOffset?: boolean): number {
const timestamp = moment(date).unix();
if (applyOffset !== undefined) {
return applyOffset ? timestamp - moment().utcOffset() * 60 : timestamp;
}
return typeof date == 'string' && date.slice(-1) == 'Z'
? timestamp - moment().utcOffset() * 60
: timestamp;
return moment(date).unix();
}
/**

View File

@ -192,9 +192,25 @@ export class TestingBehatBlockingService {
*/
protected async checkUIBlocked(): Promise<void> {
await CoreUtils.nextTick();
const blocked = document.querySelector<HTMLElement>('div.core-loading-container, ion-loading, .click-block-active');
if (blocked?.offsetParent) {
const blockingElements = Array.from(
document.querySelectorAll<HTMLElement>('div.core-loading-container, ion-loading, .click-block-active'),
);
const isBlocked = blockingElements.some(element => {
if (!element.offsetParent) {
return false;
}
const slide = element.closest('ion-slide');
if (slide && !slide.classList.contains('swiper-slide-active')) {
return false;
}
return true;
});
if (isBlocked) {
if (!this.waitingBlocked) {
this.block('blocked');
this.waitingBlocked = true;

View File

@ -27,6 +27,7 @@ import { CoreComponentsRegistry } from '@singletons/components-registry';
import { CoreDom } from '@singletons/dom';
import { Injectable } from '@angular/core';
import { CoreSites, CoreSitesProvider } from '@services/sites';
import { CoreNavigator, CoreNavigatorService } from '@services/navigator';
/**
* Behat runtime servive with public API.
@ -56,6 +57,10 @@ export class TestingBehatRuntimeService {
return CoreSites.instance;
}
get navigator(): CoreNavigatorService {
return CoreNavigator.instance;
}
/**
* Init behat functions and set options like skipping onboarding.
*
@ -436,7 +441,7 @@ export class TestingBehatRuntimeService {
return 'ERROR: No element matches field to set.';
}
const foundValue = 'value' in found ? found.value : found.innerText;
const foundValue = this.getFieldValue(found);
if (value !== foundValue) {
return `ERROR: Expecting value "${value}", found "${foundValue}" instead.`;
}
@ -457,6 +462,24 @@ export class TestingBehatRuntimeService {
);
}
/**
* Get the value of a certain field.
*
* @param element Field to get the value.
* @return Value.
*/
protected getFieldValue(element: HTMLElement | HTMLInputElement): string {
if (element.tagName === 'ION-DATETIME') {
// ion-datetime's value is a timestamp in ISO format. Use the text displayed to the user instead.
const dateTimeTextElement = element.shadowRoot?.querySelector<HTMLElement>('.datetime-text');
if (dateTimeTextElement) {
return dateTimeTextElement.innerText;
}
}
return 'value' in element ? element.value : element.innerText;
}
/**
* Get an Angular component instance.
*

View File

@ -28,8 +28,8 @@ function initializeAutomatedTests(window: AutomatedTestsWindow) {
window.behat = TestingBehatRuntime.instance;
// Force timezone for automated tests. Use the same timezone forced for LMS in tests.
moment.tz.setDefault('Australia/Perth');
// Force timezone for automated tests.
moment.tz.setDefault(CoreAppProvider.getForcedTimezone());
}
@NgModule({