Merge pull request #3431 from dpalou/MOBILE-4100

Mobile 4100
main
Pau Ferrer Ocaña 2022-11-10 16:54:03 +01:00 committed by GitHub
commit ee072610a1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 453 additions and 9 deletions

View File

@ -30,6 +30,7 @@ jobs:
MOODLE_BRANCH: ${{ github.event.inputs.moodle_branch || 'master' }}
MOODLE_REPOSITORY: ${{ github.event.inputs.moodle_repository || 'https://github.com/moodle/moodle' }}
BEHAT_TAGS: ${{ github.event.inputs.behat_tags || '~@performance' }}
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v3
@ -50,6 +51,7 @@ jobs:
export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle
cp $GITHUB_WORKSPACE/moodle-docker/config.docker-template.php $GITHUB_WORKSPACE/moodle/config.php
sed -i "61i\$CFG->behat_ionic_wwwroot = 'http://moodleapp';" $GITHUB_WORKSPACE/moodle/config.php
echo "define('TEST_MOD_BIGBLUEBUTTONBN_MOCK_SERVER', 'http://bbbmockserver/hash' . sha1(\$CFG->behat_wwwroot));" >> $GITHUB_WORKSPACE/moodle/config.php
$GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose pull
$GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose up -d
$GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-wait-for-db
@ -58,6 +60,7 @@ jobs:
docker build --build-arg build_command="npm run build:test" -t moodlehq/moodleapp:behat .
docker run -d --rm --name moodleapp moodlehq/moodleapp:behat
docker network connect moodle-docker_default moodleapp --alias moodleapp
docker run --detach --name bbbmockserver --network moodle-docker_default moodlehq/bigbluebutton_mock:latest
- name: Init Behat
run: |
export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle

View File

@ -458,10 +458,14 @@
"addon.mod_bigbluebuttonbn.view_message_conference_room_ready": "bigbluebuttonbn",
"addon.mod_bigbluebuttonbn.view_message_moderator": "bigbluebuttonbn",
"addon.mod_bigbluebuttonbn.view_message_moderators": "bigbluebuttonbn",
"addon.mod_bigbluebuttonbn.view_message_norecordings": "bigbluebuttonbn",
"addon.mod_bigbluebuttonbn.view_message_session_started_at": "bigbluebuttonbn",
"addon.mod_bigbluebuttonbn.view_message_viewer": "bigbluebuttonbn",
"addon.mod_bigbluebuttonbn.view_message_viewers": "bigbluebuttonbn",
"addon.mod_bigbluebuttonbn.view_nojoin": "bigbluebuttonbn",
"addon.mod_bigbluebuttonbn.view_recording_format_presentation": "bigbluebuttonbn",
"addon.mod_bigbluebuttonbn.view_recording_list_action_play": "bigbluebuttonbn",
"addon.mod_bigbluebuttonbn.view_section_title_recordings": "bigbluebuttonbn",
"addon.mod_book.errorchapter": "book",
"addon.mod_book.modulenameplural": "book",
"addon.mod_book.navnexttitle": "book",

View File

@ -83,6 +83,46 @@
</ion-card>
</ng-container>
<ng-container *ngIf="showRecordings && recordings">
<ion-item class="ion-text-wrap">
<ion-label>
<h2>{{ 'addon.mod_bigbluebuttonbn.view_section_title_recordings' | translate }}</h2>
</ion-label>
</ion-item>
<ng-container *ngFor="let recording of recordings">
<ion-item *ngIf="recording.url" button class="addon-mod_bbb-recording-title" [attr.aria-expanded]="recording.expanded"
(click)="toggle(recording)" [attr.aria-label]="(recording.expanded ? 'core.collapse' : 'core.expand') | translate">
<ion-icon name="fas-chevron-right" flip-rtl slot="start" aria-hidden="true" class="expandable-status-icon"
[class.expandable-status-icon-expanded]="recording.expanded">
</ion-icon>
<ion-label>
<p>{{ recording.type }}</p>
<p>{{ recording.name }}</p>
</ion-label>
<ion-button slot="end" fill="clear" (click)="playRecording($event, recording)"
[attr.aria-label]="'addon.mod_bigbluebuttonbn.view_recording_list_action_play' | translate">
<ion-icon name="fas-play-circle" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
</ion-item>
<div [hidden]="!recording.expanded" class="addon-mod_bbb-recording-details">
<ion-item *ngFor="let data of recording.details" class="ion-text-wrap">
<ion-label>
<h2>{{ data.label }}</h2>
<p *ngIf="data.allowHTML">
<core-format-text [text]="data.value" [component]="component" [componentId]="module.id" contextLevel="module"
[contextInstanceId]="module.id" [courseId]="module.course"></core-format-text>
</p>
<p *ngIf="!data.allowHTML">{{ data.value }}</p>
</ion-label>
</ion-item>
</div>
</ng-container>
<core-empty-box *ngIf="recordings && !recordings.length" icon="far-file-video"
[message]="'addon.mod_bigbluebuttonbn.view_message_norecordings' | translate">
</core-empty-box>
</ng-container>
<div collapsible-footer *ngIf="!showLoading" slot="fixed">
<div class="list-item-limited-width adaptable-buttons-row"
*ngIf="meetingInfo && showRoom && (meetingInfo.canjoin || (meetingInfo.statusrunning && meetingInfo.ismoderator))">

View File

@ -1,3 +1,26 @@
ion-item > p[slot="end"] {
font-size: var(--text-size);
:host {
--recording-details-background: var(--gray-100);
--recording-details-border: var(--gray-500);
ion-item > p[slot="end"] {
font-size: var(--text-size);
}
core-empty-box ::ng-deep p {
font-size: 100%;
}
.addon-mod_bbb-recording-details {
border-top: 2px solid var(--recording-details-border);
border-bottom: 2px solid var(--recording-details-border);
ion-item {
--background: var(--recording-details-background);
}
}
}
:host-context(html.dark) {
--recording-details-background: var(--gray-800);
--recording-details-border: var(--gray-500);
}

View File

@ -19,7 +19,10 @@ import { CoreCourseContentsPage } from '@features/course/pages/contents/contents
import { IonContent } from '@ionic/angular';
import { CoreApp } from '@services/app';
import { CoreGroupInfo, CoreGroups } from '@services/groups';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreTextUtils } from '@services/utils/text';
import { CoreTimeUtils } from '@services/utils/time';
import { CoreUtils } from '@services/utils/utils';
import { Translate } from '@singletons';
import { AddonModBBB, AddonModBBBData, AddonModBBBMeetingInfo, AddonModBBBService } from '../../services/bigbluebuttonbn';
@ -40,6 +43,7 @@ export class AddonModBBBIndexComponent extends CoreCourseModuleMainActivityCompo
groupInfo?: CoreGroupInfo;
groupId = 0;
meetingInfo?: AddonModBBBMeetingInfo;
recordings?: RecordingData[];
constructor(
protected content?: IonContent,
@ -61,6 +65,10 @@ export class AddonModBBBIndexComponent extends CoreCourseModuleMainActivityCompo
return !!this.meetingInfo && (!this.meetingInfo.features || this.meetingInfo.features.showroom);
}
get showRecordings(): boolean {
return !!this.meetingInfo && (!this.meetingInfo.features || this.meetingInfo.features.showrecordings);
}
/**
* @inheritdoc
*/
@ -79,6 +87,8 @@ export class AddonModBBBIndexComponent extends CoreCourseModuleMainActivityCompo
}
await this.fetchMeetingInfo();
await this.fetchRecordings();
}
/**
@ -113,6 +123,72 @@ export class AddonModBBBIndexComponent extends CoreCourseModuleMainActivityCompo
}
}
/**
* Get recordings.
*
* @return Promise resolved when done.
*/
async fetchRecordings(): Promise<void> {
if (!this.bbb || !this.showRecordings) {
return;
}
const recordingsTable = await AddonModBBB.getRecordings(this.bbb.id, this.groupId, {
cmId: this.module.id,
});
const columns = CoreUtils.arrayToObject(recordingsTable.columns, 'key');
this.recordings = recordingsTable.parsedData.map(recordingData => {
const playbackEl = CoreDomUtils.convertToElement(String(recordingData.playback));
const playbackAnchor = playbackEl.querySelector('a');
const details: RecordingDetailData[] = [];
Object.entries(recordingData).forEach(([key, value]) => {
const columnData = columns[key];
if (!columnData || value === '' || key === 'actionbar') {
return;
}
if (columnData.formatter === 'customDate' && !isNaN(Number(value))) {
value = CoreTimeUtils.userDate(Number(value), 'core.strftimedaydate');
} else if (columnData.allowHTML && typeof value === 'string') {
// If the HTML is empty, don't display it.
const valueElement = CoreDomUtils.convertToElement(value);
if (!valueElement.querySelector('img') && (valueElement.textContent ?? '').trim() === '') {
return;
}
if (key === 'playback') {
// Remove HTML, we're only interested in the text.
value = (valueElement.textContent ?? '').trim();
} else {
// Treat "quick edit" buttons, they aren't supported in the app.
const quickEditLink = valueElement.querySelector('.quickeditlink');
if (quickEditLink) {
// The first span in quick edit link contains the actual HTML, use it.
value = (quickEditLink.querySelector('span')?.innerHTML ?? '').trim();
}
}
}
details.push({
label: columnData.label,
value: String(value),
allowHTML: !!columnData.allowHTML,
});
});
return {
type: playbackAnchor?.innerText ??
Translate.instant('addon.mod_bigbluebuttonbn.view_recording_format_presentation'),
name: CoreTextUtils.cleanTags(String(recordingData.recording), true),
url: playbackAnchor?.href ?? '',
details,
expanded: false,
};
});
}
/**
* @inheritdoc
*/
@ -157,6 +233,7 @@ export class AddonModBBBIndexComponent extends CoreCourseModuleMainActivityCompo
if (this.bbb) {
promises.push(AddonModBBB.invalidateAllGroupsMeetingInfo(this.bbb.id));
promises.push(AddonModBBB.invalidateAllGroupsRecordings(this.bbb.id));
}
await Promise.all(promises);
@ -172,6 +249,8 @@ export class AddonModBBBIndexComponent extends CoreCourseModuleMainActivityCompo
try {
await this.fetchMeetingInfo();
await this.fetchRecordings();
} catch (error) {
CoreDomUtils.showErrorModal(error);
} finally {
@ -239,4 +318,46 @@ export class AddonModBBBIndexComponent extends CoreCourseModuleMainActivityCompo
}
}
/**
* Toogle the visibility of a recording (expand/collapse).
*
* @param recording Recording.
*/
toggle(recording: RecordingData): void {
recording.expanded = !recording.expanded;
}
/**
* Play a recording.
*
* @param event Click event.
* @param recording Recording.
*/
playRecording(event: MouseEvent, recording: RecordingData): void {
event.preventDefault();
event.stopPropagation();
CoreSites.getCurrentSite()?.openInBrowserWithAutoLogin(recording.url);
}
}
/**
* Recording data.
*/
type RecordingData = {
type: string;
name: string;
url: string;
expanded: boolean;
details: RecordingDetailData[];
};
/**
* Recording detail data.
*/
type RecordingDetailData = {
label: string;
value: string;
allowHTML: boolean;
};

View File

@ -12,8 +12,12 @@
"view_message_conference_room_ready": "This room is ready. You can join the session now.",
"view_message_moderator": "moderator",
"view_message_moderators": "moderators",
"view_message_norecordings": "There are no recordings available.",
"view_message_session_started_at": "This session started at",
"view_message_viewer": "viewer",
"view_message_viewers": "viewers",
"view_nojoin": "You do not have a role that is allowed to join this session."
"view_nojoin": "You do not have a role that is allowed to join this session.",
"view_recording_format_presentation": "Presentation",
"view_recording_list_action_play": "Play",
"view_section_title_recordings": "Recordings"
}

View File

@ -19,6 +19,7 @@ import { CoreSite, CoreSiteWSPreSets } from '@classes/site';
import { CoreCourseCommonModWSOptions } from '@features/course/services/course';
import { CoreCourseLogHelper } from '@features/course/services/log-helper';
import { CoreSites, CoreSitesCommonWSOptions } from '@services/sites';
import { CoreTextUtils } from '@services/utils/text';
import { CoreUtils } from '@services/utils/utils';
import { CoreWSExternalFile, CoreWSExternalWarning } from '@services/ws';
import { makeSingleton, Translate } from '@singletons';
@ -206,6 +207,71 @@ export class AddonModBBBService {
return ROOT_CACHE_KEY + 'meetingInfo:' + id + ':';
}
/**
* Get meeting info for a BBB activity.
*
* @param id BBB ID.
* @param groupId Group ID, 0 means that the function will determine the user group.
* @param options Other options.
* @return Promise resolved with the list of messages.
*/
async getRecordings(
id: number,
groupId: number = 0,
options: AddonModBBBGetMeetingInfoOptions = {},
): Promise<AddonModBBBRecordingsTableData> {
const site = await CoreSites.getSite(options.siteId);
const params: AddonModBBBGetRecordingsWSParams = {
bigbluebuttonbnid: id,
groupid: groupId,
};
const preSets: CoreSiteWSPreSets = {
cacheKey: this.getRecordingsCacheKey(id, groupId),
component: AddonModBBBService.COMPONENT,
componentId: options.cmId,
...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
const result = await site.read<AddonModBBBGetRecordingsWSResponse>(
'mod_bigbluebuttonbn_get_recordings',
params,
preSets,
);
if (result.warnings?.length) {
throw new CoreWSError(result.warnings[0]);
} else if (!result.tabledata) {
throw new CoreError('Cannot retrieve recordings.');
}
return {
...result.tabledata,
parsedData: CoreTextUtils.parseJSON(result.tabledata?.data, []),
};
}
/**
* Get cache key for get recordings WS call.
*
* @param id BBB ID.
* @param groupId Group ID, 0 means that the function will determine the user group.
* @return Cache key.
*/
protected getRecordingsCacheKey(id: number, groupId: number = 0): string {
return this.getRecordingsCacheKeyPrefix(id) + groupId;
}
/**
* Get cache key prefix for get recordings WS call.
*
* @param id BBB ID.
* @return Cache key prefix.
*/
protected getRecordingsCacheKeyPrefix(id: number): string {
return ROOT_CACHE_KEY + 'recordings:' + id + ':';
}
/**
* Report a BBB as being viewed.
*
@ -271,6 +337,33 @@ export class AddonModBBBService {
await site.invalidateWsCacheForKeyStartingWith(this.getMeetingInfoCacheKeyPrefix(id));
}
/**
* Invalidate recordings for a certain group.
*
* @param id BBB ID.
* @param groupId Group ID, 0 means that the function will determine the user group.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when the data is invalidated.
*/
async invalidateRecordings(id: number, groupId: number = 0, siteId?: string): Promise<void> {
const site = await CoreSites.getSite(siteId);
await site.invalidateWsCacheForKey(this.getRecordingsCacheKey(id, groupId));
}
/**
* Invalidate recordings for all groups.
*
* @param id BBB ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when the data is invalidated.
*/
async invalidateAllGroupsRecordings(id: number, siteId?: string): Promise<void> {
const site = await CoreSites.getSite(siteId);
await site.invalidateWsCacheForKeyStartingWith(this.getRecordingsCacheKeyPrefix(id));
}
/**
* Returns whether or not the BBB plugin is enabled for a certain site.
*
@ -410,3 +503,48 @@ export type AddonModBBBEndMeetingWSParams = {
export type AddonModBBBGetMeetingInfoOptions = CoreCourseCommonModWSOptions & {
updateCache?: boolean;
};
/**
* Params of mod_bigbluebuttonbn_get_recordings WS.
*/
export type AddonModBBBGetRecordingsWSParams = {
bigbluebuttonbnid: number; // Bigbluebuttonbn instance id.
tools?: string; // A set of enabled tools.
groupid?: number; // Group ID.
};
/**
* Data returned by mod_bigbluebuttonbn_get_recordings WS.
*/
export type AddonModBBBGetRecordingsWSResponse = {
status: boolean; // Whether the fetch was successful.
tabledata?: AddonModBBBRecordingsWSTableData;
warnings?: CoreWSExternalWarning[];
};
/**
* Table data returned by mod_bigbluebuttonbn_get_recordings WS.
*/
export type AddonModBBBRecordingsWSTableData = {
activity: string;
ping_interval: number; // eslint-disable-line @typescript-eslint/naming-convention
locale: string;
profile_features: string[]; // eslint-disable-line @typescript-eslint/naming-convention
columns: {
key: string;
label: string;
width: string;
type?: string; // Column type.
sortable?: boolean; // Whether this column is sortable.
allowHTML?: boolean; // Whether this column contains HTML.
formatter?: string; // Formatter name.
}[];
data: string;
};
/**
* Recordings table data with some calculated data.
*/
export type AddonModBBBRecordingsTableData = AddonModBBBRecordingsWSTableData & {
parsedData: Record<string, string|number|boolean>[];
};

View File

@ -97,3 +97,77 @@ Feature: Test basic usage of BBB activity in app
When I close all opened windows
And I press "Join session" in the app
Then the app should have opened a browser tab with url "blindsidenetworks.com"
@lms_from4.1
Scenario: Display right info based on instance type
Given the following "activities" exist:
| activity | name | course | idnumber | type |
| bigbluebuttonbn | Room & recordings | C1 | bbb1 | 0 |
| bigbluebuttonbn | Room only | C1 | bbb2 | 1 |
| bigbluebuttonbn | Recordings only | C1 | bbb3 | 2 |
And I entered the bigbluebuttonbn activity "Room & recordings" on course "Course 1" as "student1" in the app
Then I should find "This room is ready. You can join the session now." in the app
And I should be able to press "Join session" in the app
And I should find "Recordings" in the app
And I should find "There are no recordings available." in the app
When I press the back button in the app
And I press "Room only" in the app
Then I should find "This room is ready. You can join the session now." in the app
And I should be able to press "Join session" in the app
But I should not find "Recordings" in the app
When I press the back button in the app
And I press "Recordings only" in the app
Then I should find "Recordings" in the app
But I should not find "This room is ready. You can join the session now." in the app
And I should not be able to press "Join session" in the app
# Test recordings requires a BBB mock server. If you're using docker, you can run the BBB mock server with this command:
#
# docker run --name bbbmockserver -p 8001:80 moodlehq/bigbluebutton_mock:latest
#
# You also need to edit the config.php of your Moodle site to add this line:
#
# define('TEST_MOD_BIGBLUEBUTTONBN_MOCK_SERVER', 'http://bbbmockserver:8001/hash' . sha1($CFG->behat_wwwroot));
Scenario: View recordings
Given a BigBlueButton mock server is configured
And the following "activities" exist:
| activity | name | course | idnumber | type | recordings_imported |
| bigbluebuttonbn | BBB | C1 | bbb1 | 0 | 0 |
And the following "mod_bigbluebuttonbn > meeting" exists:
| activity | BBB |
And the following "mod_bigbluebuttonbn > recordings" exist:
| bigbluebuttonbn | name | description | status |
| BBB | Recording 1 | Description 1 | 3 |
| BBB | Recording 2 | Description 2 | 3 |
And I entered the bigbluebuttonbn activity "BBB" on course "Course 1" as "student1" in the app
Then I should find "Presentation" in the app
And I should find "Recording 1" in the app
And I should find "Recording 2" in the app
But I should not find "Description 1" in the app
And I should not find "Description 2" in the app
When I press "Recording 1" in the app
Then I should find "Description 1" in the app
And I should find "Presentation" within "Playback" "ion-item" in the app
And I should find "Recording 1" within "Name" "ion-item" in the app
And I should find "Date" in the app
And I should find "3600" within "Duration" "ion-item" in the app
But I should not find "Description 2" in the app
When I press "Recording 1" in the app
Then I should not find "Description 1" in the app
When I press "Recording 2" in the app
Then I should find "Description 2" in the app
And I should find "Presentation" within "Playback" "ion-item" in the app
And I should find "Recording 2" within "Name" "ion-item" in the app
But I should not find "Description 1" in the app
# Test play button, but the mock server doesn't support viewing recordings.
When I press "Play" near "Recording 1" 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 "The recording URL is invalid"

View File

@ -81,3 +81,29 @@ Feature: Test usage of BBB activity with groups in app
And I press "Group 2" in the app
Then I should find "This room is ready. You can join the session now." in the app
And I should be able to press "Join session" in the app
Scenario: View recordings
Given a BigBlueButton mock server is configured
And the following "activities" exist:
| activity | name | course | idnumber | wait | groupmode | type | recordings_imported |
| bigbluebuttonbn | Test BBB | C1 | bbb1 | 0 | 2 | 0 | 0 |
And the following "mod_bigbluebuttonbn > meeting" exists:
| activity | Test BBB |
And the following "mod_bigbluebuttonbn > meetings" exist:
| activity | group |
| Test BBB | G1 |
| Test BBB | G2 |
And the following "mod_bigbluebuttonbn > recordings" exist:
| bigbluebuttonbn | name | description | status | group |
| Test BBB | Recording 1 | Description 1 | 3 | G1 |
| Test BBB | Recording 2 | Description 2 | 3 | G2 |
And I entered the bigbluebuttonbn activity "Test BBB" on course "Course 1" as "student1" in the app
When I press "Visible groups" in the app
And I press "Group 1" in the app
Then I should find "Recording 1" in the app
But I should not find "Recording 2" in the app
When I press "Visible groups" in the app
And I press "Group 2" in the app
Then I should find "Recording 2" in the app
But I should not find "Recording 1" in the app

View File

@ -38,6 +38,7 @@ export class CoreCollapsibleFooterDirective implements OnInit, OnDestroy {
@Input() appearOnBottom = false;
protected id = '0';
protected element: HTMLElement;
protected initialHeight = 48;
protected finalHeight = 0;
@ -63,6 +64,8 @@ export class CoreCollapsibleFooterDirective implements OnInit, OnDestroy {
* @inheritdoc
*/
async ngOnInit(): Promise<void> {
this.id = String(CoreUtils.getUniqueId('CoreCollapsibleFooterDirective'));
// Only if not present or explicitly falsy it will be false.
this.appearOnBottom = !CoreUtils.isFalseOrZero(this.appearOnBottom);
this.slotPromise = CoreDom.slotOnContent(this.element);
@ -72,6 +75,7 @@ export class CoreCollapsibleFooterDirective implements OnInit, OnDestroy {
await this.waitFormatTextsRendered();
this.content = this.element.closest('ion-content');
this.content?.setAttribute('data-collapsible-footer-id', this.id);
await this.calculateHeight();
@ -242,14 +246,21 @@ export class CoreCollapsibleFooterDirective implements OnInit, OnDestroy {
* @inheritdoc
*/
async ngOnDestroy(): Promise<void> {
this.content?.style.setProperty('--padding-bottom', this.initialPaddingBottom);
if (this.content) {
// Only reset the variables and classes if the collapsible footer hasn't changed (avoid race conditions).
if (this.content.getAttribute('data-collapsible-footer-id') === this.id) {
this.content.style.setProperty('--padding-bottom', this.initialPaddingBottom);
this.content.classList.remove('has-collapsible-footer');
}
if (this.content && this.contentScrollListener) {
this.content.removeEventListener('ionScroll', this.contentScrollListener);
}
if (this.content && this.endContentScrollListener) {
this.content.removeEventListener('ionScrollEnd', this.endContentScrollListener);
if (this.contentScrollListener) {
this.content.removeEventListener('ionScroll', this.contentScrollListener);
}
if (this.endContentScrollListener) {
this.content.removeEventListener('ionScrollEnd', this.endContentScrollListener);
}
}
if (this.page && this.pageDidEnterListener) {
this.page.removeEventListener('ionViewDidEnter', this.pageDidEnterListener);
}