Merge pull request #3570 from moodlehq/integration

Integration
main
Moodle Mobile Team 2023-02-28 11:01:52 +01:00 committed by GitHub
commit 15fafef5f0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
329 changed files with 6615 additions and 1449 deletions

View File

@ -26,7 +26,7 @@ jobs:
env:
MOODLE_DOCKER_DB: pgsql
MOODLE_DOCKER_BROWSER: chrome
MOODLE_DOCKER_PHP_VERSION: 7.4
MOODLE_DOCKER_PHP_VERSION: '8.0'
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' }}
@ -42,6 +42,12 @@ jobs:
git clone --branch master --depth 1 https://github.com/moodlehq/moodle-docker $GITHUB_WORKSPACE/moodle-docker
- name: Install npm packages
run: npm ci --no-audit
- name: Create Behat faildumps folder
run: |
mkdir moodle/behatfaildumps
chmod 777 moodle/behatfaildumps
- name: Install Behat Snapshots plugin
run: git clone --branch main --depth 1 https://github.com/NoelDeMartin/moodle-local_behatsnapshots $GITHUB_WORKSPACE/moodle/local/behatsnapshots
- name: Generate Behat tests plugin
run: |
export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle
@ -50,12 +56,21 @@ jobs:
run: |
export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle
cp $GITHUB_WORKSPACE/moodle-docker/config.docker-template.php $GITHUB_WORKSPACE/moodle/config.php
sed -i "61c\$CFG->behat_faildump_path = '/var/www/html/behatfaildumps';" $GITHUB_WORKSPACE/moodle/config.php
sed -i "61i\$CFG->behat_increasetimeout = 2;" $GITHUB_WORKSPACE/moodle/config.php
sed -i "61i\$CFG->behat_ionic_wwwroot = 'http://moodleapp';" $GITHUB_WORKSPACE/moodle/config.php
sed -i "61i\$CFG->behat_snapshots_path = '/var/www/html/local/moodleappbehat/tests/behat/snapshots';" $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
- name: Install Imagick PHP extension
run: |
export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle
./moodle-docker/bin/moodle-docker-compose exec webserver apt-get update
./moodle-docker/bin/moodle-docker-compose exec webserver apt-get install -y libmagickwand-dev --no-install-recommends
./moodle-docker/bin/moodle-docker-compose exec webserver pecl install imagick
./moodle-docker/bin/moodle-docker-compose exec webserver docker-php-ext-enable imagick
- name: Compile & launch app with Docker
run: |
docker build --build-arg build_command="npm run build:test" -t moodlehq/moodleapp:behat .
@ -65,8 +80,20 @@ jobs:
- name: Init Behat
run: |
export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle
$GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose exec -T webserver sh -c "php admin/tool/behat/cli/init.php --parallel=8 --optimize-runs='@app&&$BEHAT_TAGS'"
$GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose exec -T webserver sh -c "php admin/tool/behat/cli/init.php --parallel=8 --optimize-runs='@app&&~@local&&$BEHAT_TAGS'"
- name: Run Behat tests
run: |
export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle
$GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose exec -T webserver sh -c "php admin/tool/behat/cli/run.php --verbose --tags='@app&&$BEHAT_TAGS' --auto-rerun=3"
$GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose exec -T webserver sh -c "php admin/tool/behat/cli/run.php --verbose --tags='@app&&~@local&&$BEHAT_TAGS' --auto-rerun=3"
- name: Upload Snapshot failures
uses: actions/upload-artifact@v3
if: ${{ failure() }}
with:
name: snapshot_failures
path: moodle/local/moodleappbehat/tests/behat/snapshots/failures/*
- name: Upload Behat failures
uses: actions/upload-artifact@v3
if: ${{ failure() }}
with:
name: behat_failures
path: moodle/behatfaildumps

View File

@ -8,7 +8,7 @@ jobs:
env:
MOODLE_DOCKER_DB: pgsql
MOODLE_DOCKER_BROWSER: chrome
MOODLE_DOCKER_PHP_VERSION: 7.4
MOODLE_DOCKER_PHP_VERSION: '8.0'
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v3

View File

@ -6,6 +6,8 @@ WORKDIR /app
# Prepare node dependencies
RUN apt-get update && apt-get install libsecret-1-0 -y
COPY package*.json ./
COPY patches ./patches
RUN echo "unsafe-perm=true" > ./.npmrc
RUN npm ci --no-audit
# Build source

View File

@ -4,10 +4,12 @@ Moodle App
This is the primary repository of source code for the official mobile app for Moodle.
* [User documentation](https://docs.moodle.org/en/Moodle_app)
* [Developer documentation](http://docs.moodle.org/dev/Moodle_App)
* [Development environment setup](https://docs.moodle.org/dev/Setting_up_your_development_environment_for_the_Moodle_App)
* [Developer documentation](https://moodledev.io/general/app)
* [Development environment setup](https://moodledev.io/general/app/development/setup)
* [Bug Tracker](https://tracker.moodle.org/browse/MOBILE)
* [Release Notes](https://docs.moodle.org/dev/Moodle_App_Release_Notes)
* [Release Notes](https://moodledev.io/general/app_releases)
This project is tested with BrowserStack.
License
-------

View File

@ -42,7 +42,8 @@
"input": "src/theme/theme.scss"
}
],
"scripts": []
"scripts": [],
"webWorkerTsConfig": "tsconfig.worker.json"
},
"configurations": {
"production": {
@ -50,6 +51,10 @@
{
"replace": "src/testing/testing.module.ts",
"with": "src/testing/testing.module.prod.ts"
},
{
"replace": "src/core/features/emulator/emulator.module.ts",
"with": "src/core/features/emulator/emulator.module.prod.ts"
}
],
"optimization": {

View File

@ -1,5 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<widget android-versionCode="41001" id="com.moodle.moodlemobile" ios-CFBundleVersion="4.1.0.1" version="4.1.0" versionCode="41001" xmlns="http://www.w3.org/ns/widgets" xmlns:android="http://schemas.android.com/apk/res/android" xmlns:cdv="http://cordova.apache.org/ns/1.0">
<widget android-versionCode="41100" id="com.moodle.moodlemobile" ios-CFBundleVersion="4.1.1.0" version="4.1.1" versionCode="41100" xmlns="http://www.w3.org/ns/widgets" xmlns:android="http://schemas.android.com/apk/res/android" xmlns:cdv="http://cordova.apache.org/ns/1.0">
<name>Moodle</name>
<description>Moodle official app</description>
<author email="mobile@moodle.com" href="http://moodle.com">Moodle Mobile team</author>
@ -27,7 +27,7 @@
<preference name="UIWebViewBounce" value="false" />
<preference name="DisallowOverscroll" value="true" />
<preference name="prerendered-icon" value="true" />
<preference name="AppendUserAgent" value="MoodleMobile 4.1.0 (41001)" />
<preference name="AppendUserAgent" value="MoodleMobile 4.1.1 (41100)" />
<preference name="BackupWebStorage" value="none" />
<preference name="ScrollEnabled" value="false" />
<preference name="KeyboardDisplayRequiresUserAction" value="false" />
@ -196,13 +196,9 @@
<param name="android-package" value="com.adobe.phonegap.push.PushPlugin" />
</feature>
</config-file>
<config-file parent="/*" target="res/xml/config.xml">
<feature name="Media">
<param name="android-package" value="org.apache.cordova.media.AudioHandler" />
</feature>
</config-file>
<config-file parent="/*" target="AndroidManifest.xml">
<uses-feature android:name="android.hardware.bluetooth" android:required="false" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
</config-file>
<config-file parent="/*" target="AndroidManifest.xml">
<queries>
@ -236,7 +232,7 @@
<true />
</edit-config>
<edit-config file="*-Info.plist" mode="merge" target="CFBundleShortVersionString">
<string>4.1.0</string>
<string>4.1.1</string>
</edit-config>
<edit-config file="*-Info.plist" mode="overwrite" target="CFBundleLocalizations">
<array>

View File

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

View File

@ -19,6 +19,7 @@
require_once(__DIR__ . '/../../../../lib/behat/behat_base.php');
require_once(__DIR__ . '/behat_app_helper.php');
use Behat\Behat\Hook\Scope\ScenarioScope;
use Behat\Gherkin\Node\TableNode;
use Behat\Mink\Exception\DriverException;
use Behat\Mink\Exception\ExpectationException;
@ -45,6 +46,27 @@ class behat_app extends behat_app_helper {
protected $windowsize = '360x720';
/**
* @BeforeScenario
*/
public function before_scenario(ScenarioScope $scope) {
if (!$scope->getFeature()->hasTag('app')) {
return;
}
global $CFG;
$performanceLogs = $CFG->behat_profiles['default']['capabilities']['extra_capabilities']['goog:loggingPrefs']['performance'] ?? null;
if ($performanceLogs !== 'ALL') {
return;
}
// Enable DB Logging only for app tests with performance logs activated.
$this->getSession()->visit($this->get_app_url() . '/assets/env.json');
$this->execute_script("document.cookie = 'MoodleAppDBLoggingEnabled=true;path=/';");
}
/**
* Opens the Moodle App in the browser and optionally logs in.
*
@ -215,13 +237,21 @@ class behat_app extends behat_app_helper {
/**
* Trigger swipe gesture.
*
* @When /^I swipe to the (left|right) in the app$/
* @When /^I swipe to the (left|right) (in (".+") )?in the app$/
* @param string $direction Swipe direction
* @param bool $hasLocator Whether a reference locator is used.
* @param string $locator Reference locator.
*/
public function i_swipe_in_the_app(string $direction) {
$method = 'swipe' . ucwords($direction);
public function i_swipe_in_the_app(string $direction, bool $hasLocator = false, string $locator = '') {
if ($hasLocator) {
$locator = $this->parse_element_locator($locator);
}
$this->zone_js("getAngularInstance('ion-content', 'CoreSwipeNavigationDirective').$method()");
$result = $this->zone_js("swipe('$direction'" . ($hasLocator ? ", $locator" : '') . ')');
if ($result !== 'OK') {
throw new DriverException('Error when swiping - ' . $result);
}
$this->wait_for_pending_js();

View File

@ -1,8 +1,8 @@
{
"app_id": "com.moodle.moodlemobile",
"appname": "Moodle Mobile",
"versioncode": 41001,
"versionname": "4.1.0",
"versioncode": 41100,
"versionname": "4.1.1",
"cache_update_frequency_usually": 420000,
"cache_update_frequency_often": 1200000,
"cache_update_frequency_sometimes": 3600000,

357
package-lock.json generated
View File

@ -1,6 +1,6 @@
{
"name": "moodlemobile",
"version": "4.1.0",
"version": "4.1.1",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@ -3618,7 +3618,6 @@
"version": "7.9.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.9.6.tgz",
"integrity": "sha512-64AF1xY3OAkFHqOb9s4jpgk1Mm5vDZ4L3acHvAml+53nO1XbXLuDodsVpO4OIUsmemlUHMxNdYMNJmsvOwLrvQ==",
"dev": true,
"requires": {
"regenerator-runtime": "^0.13.4"
}
@ -4168,21 +4167,6 @@
}
}
},
"@ionic-native/media": {
"version": "5.36.0",
"resolved": "https://registry.npmjs.org/@ionic-native/media/-/media-5.36.0.tgz",
"integrity": "sha512-WIDCeUlX7bCbse/x2Rr7mAIQJnLo18ZWcmsVgSTTBVS7ObU2DBl4ieqRx6y9PAAV+3tNZqMV4JAWDfMiFokpJg==",
"requires": {
"@types/cordova": "^0.0.34"
},
"dependencies": {
"@types/cordova": {
"version": "0.0.34",
"resolved": "https://registry.npmjs.org/@types/cordova/-/cordova-0.0.34.tgz",
"integrity": "sha512-rkiiTuf/z2wTd4RxFOb+clE7PF4AEJU0hsczbUdkHHBtkUmpWQpEddynNfJYKYtZFJKbq4F+brfekt1kx85IZA=="
}
}
},
"@ionic-native/media-capture": {
"version": "5.36.0",
"resolved": "https://registry.npmjs.org/@ionic-native/media-capture/-/media-capture-5.36.0.tgz",
@ -4319,11 +4303,11 @@
}
},
"@ionic/angular": {
"version": "5.9.2",
"resolved": "https://registry.npmjs.org/@ionic/angular/-/angular-5.9.2.tgz",
"integrity": "sha512-5GzKg+l4au3xFECky2v/USlRsmTAXgvNO5Zalt7NUXc//VJIL2lQvswojE6FBWuM/xR5W0CWbJdFth19TaZWVQ==",
"version": "5.9.4",
"resolved": "https://registry.npmjs.org/@ionic/angular/-/angular-5.9.4.tgz",
"integrity": "sha512-U/85FePF48VaZXTudTwpVXDqhGmYfarl/7vki7a4umnIORnWtHqD2/pXsqqZ/O1EcbALwULYIeVXAfkFpPd2wQ==",
"requires": {
"@ionic/core": "5.9.2",
"@ionic/core": "5.9.4",
"tslib": "^1.9.3"
},
"dependencies": {
@ -4666,9 +4650,9 @@
}
},
"@ionic/core": {
"version": "5.9.2",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-5.9.2.tgz",
"integrity": "sha512-1ZqSBS8R6tGQsc+LsLxIRv0q3Ww6jwgJXLvdn6FmVWfpPbBvT+CjCuU9hqJ5qwM+atErblUMYSexvvpws8lGAA==",
"version": "5.9.4",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-5.9.4.tgz",
"integrity": "sha512-Ngz9yVT6fIiGdSxxBer8uJxP4w6PasvohYpLxhtMgYiWnyIu0vZra2ui3HrYukCzUo5/SbNPiUr1l7cj1E+7qw==",
"requires": {
"@stencil/core": "^2.4.0",
"ionicons": "^5.5.3",
@ -5852,9 +5836,9 @@
}
},
"@stencil/core": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@stencil/core/-/core-2.11.0.tgz",
"integrity": "sha512-/IubCWhVXCguyMUp/3zGrg3c882+RJNg/zpiKfyfJL3kRCOwe+/MD8OoAXVGdd+xAohZKIi1Ik+EHFlsptsjLg=="
"version": "2.22.2",
"resolved": "https://registry.npmjs.org/@stencil/core/-/core-2.22.2.tgz",
"integrity": "sha512-r+vbxsGNcBaV1VDOYW25lv4QfXTlNoIb5GpUX7rZ+cr59yqYCZC5tlV+IzX6YgHKW62ulCc9M3RYtTfHtNbNNw=="
},
"@storybook/addon-controls": {
"version": "6.1.21",
@ -9255,6 +9239,71 @@
"eslint-visitor-keys": "^2.0.0"
}
},
"@videojs/http-streaming": {
"version": "2.15.1",
"resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-2.15.1.tgz",
"integrity": "sha512-/uuN3bVkEeJAdrhu5Hyb19JoUo3CMys7yf2C1vUjeL1wQaZ4Oe8JrZzRrnWZ0rjvPgKfNLPXQomsRtgrMoRMJQ==",
"requires": {
"@babel/runtime": "^7.12.5",
"@videojs/vhs-utils": "3.0.5",
"aes-decrypter": "3.1.3",
"global": "^4.4.0",
"m3u8-parser": "4.8.0",
"mpd-parser": "^0.22.1",
"mux.js": "6.0.1",
"video.js": "^6 || ^7"
},
"dependencies": {
"@babel/runtime": {
"version": "7.20.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.7.tgz",
"integrity": "sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ==",
"requires": {
"regenerator-runtime": "^0.13.11"
}
},
"regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="
}
}
},
"@videojs/vhs-utils": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@videojs/vhs-utils/-/vhs-utils-3.0.5.tgz",
"integrity": "sha512-PKVgdo8/GReqdx512F+ombhS+Bzogiofy1LgAj4tN8PfdBx3HSS7V5WfJotKTqtOWGwVfSWsrYN/t09/DSryrw==",
"requires": {
"@babel/runtime": "^7.12.5",
"global": "^4.4.0",
"url-toolkit": "^2.2.1"
},
"dependencies": {
"@babel/runtime": {
"version": "7.20.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.7.tgz",
"integrity": "sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ==",
"requires": {
"regenerator-runtime": "^0.13.11"
}
},
"regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="
}
}
},
"@videojs/xhr": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@videojs/xhr/-/xhr-2.6.0.tgz",
"integrity": "sha512-7J361GiN1tXpm+gd0xz2QWr3xNWBE+rytvo8J3KuggFaLg+U37gZQ2BuPLcnkfGffy2e+ozY70RHC8jt7zjA6Q==",
"requires": {
"@babel/runtime": "^7.5.5",
"global": "~4.4.0",
"is-function": "^1.0.1"
}
},
"@webassemblyjs/ast": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz",
@ -9430,6 +9479,11 @@
"@xtuc/long": "4.2.2"
}
},
"@xmldom/xmldom": {
"version": "0.8.6",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.6.tgz",
"integrity": "sha512-uRjjusqpoqfmRkTaNuLJ2VohVr67Q5YwDATW3VU7PfzTj6IRaihGrYI7zckGZjxQPBIp63nfvJbM+Yu5ICh0Bg=="
},
"@xtuc/ieee754": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
@ -9567,6 +9621,32 @@
}
}
},
"aes-decrypter": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/aes-decrypter/-/aes-decrypter-3.1.3.tgz",
"integrity": "sha512-VkG9g4BbhMBy+N5/XodDeV6F02chEk9IpgRTq/0bS80y4dzy79VH2Gtms02VXomf3HmyRe3yyJYkJ990ns+d6A==",
"requires": {
"@babel/runtime": "^7.12.5",
"@videojs/vhs-utils": "^3.0.5",
"global": "^4.4.0",
"pkcs7": "^1.0.4"
},
"dependencies": {
"@babel/runtime": {
"version": "7.20.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.7.tgz",
"integrity": "sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ==",
"requires": {
"regenerator-runtime": "^0.13.11"
}
},
"regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="
}
}
},
"agent-base": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz",
@ -14245,11 +14325,6 @@
"resolved": "https://registry.npmjs.org/cordova-plugin-ionic-keyboard/-/cordova-plugin-ionic-keyboard-2.2.0.tgz",
"integrity": "sha512-yDUG+9ieKVRitq5mGlNxjaZh/MgEhFFIgTIPhqSbUaQ8UuZbawy5mhJAVClqY97q8/rcQtL6dCDa7x2sEtCLcA=="
},
"cordova-plugin-media": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/cordova-plugin-media/-/cordova-plugin-media-5.0.4.tgz",
"integrity": "sha512-mAqincYqOT5gu5LWyfgJu3qmOq+lhLAKhnOZULpG622FvYiHjjfsoJ/fkI55WwI3FIcHeeyhToGvHXBCNJePZg=="
},
"cordova-plugin-media-capture": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/cordova-plugin-media-capture/-/cordova-plugin-media-capture-3.0.3.tgz",
@ -15674,8 +15749,7 @@
"dom-walk": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==",
"dev": true
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
},
"domain-browser": {
"version": "1.2.0",
@ -16760,6 +16834,11 @@
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc="
},
"event-target-shim": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-6.0.2.tgz",
"integrity": "sha512-8q3LsZjRezbFZ2PN+uP+Q7pnHUMmAOziU2vA2OwoFaKIXxlxl38IylhSSgUorWu/rf4er67w0ikBqjBFk/pomA=="
},
"eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
@ -18627,7 +18706,6 @@
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz",
"integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==",
"dev": true,
"requires": {
"min-document": "^2.19.0",
"process": "^0.11.10"
@ -20030,6 +20108,11 @@
"integrity": "sha1-8w9xbI4r00bHtn0985FVZqfAVgc=",
"dev": true
},
"individual": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/individual/-/individual-2.0.0.tgz",
"integrity": "sha512-pWt8hBCqJsUWI/HtcfWod7+N9SgAqyPEaF7JQjwzjn5vGrpg6aQ5qeAFQ7dx//UH4J1O+7xqew+gCeeFt6xN/g=="
},
"infer-owner": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz",
@ -20545,8 +20628,7 @@
"is-function": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz",
"integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==",
"dev": true
"integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ=="
},
"is-generator-fn": {
"version": "2.1.0",
@ -22371,6 +22453,11 @@
"source-map-support": "^0.5.5"
}
},
"keycode": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/keycode/-/keycode-2.2.1.tgz",
"integrity": "sha512-Rdgz9Hl9Iv4QKi8b0OlCRQEzp4AgVxyCtz5S/+VIHezDmrDhkp2N2TqBWOLz0/gbeREXOOiI9/4b8BY9uw2vFg=="
},
"keytar": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/keytar/-/keytar-7.2.0.tgz",
@ -22940,6 +23027,31 @@
"yallist": "^4.0.0"
}
},
"m3u8-parser": {
"version": "4.8.0",
"resolved": "https://registry.npmjs.org/m3u8-parser/-/m3u8-parser-4.8.0.tgz",
"integrity": "sha512-UqA2a/Pw3liR6Df3gwxrqghCP17OpPlQj6RBPLYygf/ZSQ4MoSgvdvhvt35qV+3NaaA0FSZx93Ix+2brT1U7cA==",
"requires": {
"@babel/runtime": "^7.12.5",
"@videojs/vhs-utils": "^3.0.5",
"global": "^4.4.0"
},
"dependencies": {
"@babel/runtime": {
"version": "7.20.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.7.tgz",
"integrity": "sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ==",
"requires": {
"regenerator-runtime": "^0.13.11"
}
},
"regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="
}
}
},
"macos-release": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/macos-release/-/macos-release-2.5.0.tgz",
@ -23425,7 +23537,6 @@
"version": "2.19.0",
"resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz",
"integrity": "sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==",
"dev": true,
"requires": {
"dom-walk": "^0.1.0"
}
@ -23708,6 +23819,41 @@
}
}
},
"mp3-mediarecorder": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/mp3-mediarecorder/-/mp3-mediarecorder-4.0.5.tgz",
"integrity": "sha512-tu8XvKGMrdwNmEQTzBbaJRLBAuVNEzbzmCOnYzUyYuEb48Kwl97qA6f5nBEaZXveNmHgvvi0i85TjROPC49qFA==",
"requires": {
"event-target-shim": "6.0.2",
"vmsg": "0.4.0"
}
},
"mpd-parser": {
"version": "0.22.1",
"resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-0.22.1.tgz",
"integrity": "sha512-fwBebvpyPUU8bOzvhX0VQZgSohncbgYwUyJJoTSNpmy7ccD2ryiCvM7oRkn/xQH5cv73/xU7rJSNCLjdGFor0Q==",
"requires": {
"@babel/runtime": "^7.12.5",
"@videojs/vhs-utils": "^3.0.5",
"@xmldom/xmldom": "^0.8.3",
"global": "^4.4.0"
},
"dependencies": {
"@babel/runtime": {
"version": "7.20.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.7.tgz",
"integrity": "sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ==",
"requires": {
"regenerator-runtime": "^0.13.11"
}
},
"regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="
}
}
},
"ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@ -23740,6 +23886,30 @@
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA=="
},
"mux.js": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/mux.js/-/mux.js-6.0.1.tgz",
"integrity": "sha512-22CHb59rH8pWGcPGW5Og7JngJ9s+z4XuSlYvnxhLuc58cA1WqGDQPzuG8I+sPm1/p0CdgpzVTaKW408k5DNn8w==",
"requires": {
"@babel/runtime": "^7.11.2",
"global": "^4.4.0"
},
"dependencies": {
"@babel/runtime": {
"version": "7.20.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.7.tgz",
"integrity": "sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ==",
"requires": {
"regenerator-runtime": "^0.13.11"
}
},
"regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="
}
}
},
"nan": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.14.1.tgz",
@ -24875,6 +25045,29 @@
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==",
"dev": true
},
"ogv": {
"version": "1.8.9",
"resolved": "https://registry.npmjs.org/ogv/-/ogv-1.8.9.tgz",
"integrity": "sha512-tQA2E3E2PzdWqxIaI5X8q8Vxvj1Ap3JSZmD1MfnA+cTY3o0t+06zY4RKXckQ9pxeqGy/UH4l4QensssmbPLwAQ==",
"requires": {
"@babel/runtime": "^7.16.7"
},
"dependencies": {
"@babel/runtime": {
"version": "7.20.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.7.tgz",
"integrity": "sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ==",
"requires": {
"regenerator-runtime": "^0.13.11"
}
},
"regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="
}
}
},
"on-finished": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
@ -25905,6 +26098,14 @@
"node-modules-regexp": "^1.0.0"
}
},
"pkcs7": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/pkcs7/-/pkcs7-1.0.4.tgz",
"integrity": "sha512-afRERtHn54AlwaF2/+LFszyAANTCggGilmcmILUzEjvs3XgFZT+xE6+QWQcAGmu4xajy+Xtj7acLOPdx5/eXWQ==",
"requires": {
"@babel/runtime": "^7.5.5"
}
},
"pkg-dir": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz",
@ -26858,8 +27059,7 @@
"process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha1-czIwDoQBYb2j5podHZGn1LwW8YI=",
"dev": true
"integrity": "sha1-czIwDoQBYb2j5podHZGn1LwW8YI="
},
"process-nextick-args": {
"version": "2.0.1",
@ -28270,8 +28470,7 @@
"regenerator-runtime": {
"version": "0.13.5",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz",
"integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA==",
"dev": true
"integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA=="
},
"regenerator-transform": {
"version": "0.14.5",
@ -28944,6 +29143,14 @@
"aproba": "^1.1.1"
}
},
"rust-result": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/rust-result/-/rust-result-1.0.0.tgz",
"integrity": "sha512-6cJzSBU+J/RJCF063onnQf0cDUOHs9uZI1oroSGnHOph+CQTIJ5Pp2hK5kEQq1+7yE/EEWfulSNXAQ2jikPthA==",
"requires": {
"individual": "^2.0.0"
}
},
"rxjs": {
"version": "6.5.5",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.5.tgz",
@ -28964,6 +29171,14 @@
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
},
"safe-json-parse": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/safe-json-parse/-/safe-json-parse-4.0.0.tgz",
"integrity": "sha512-RjZPPHugjK0TOzFrLZ8inw44s9bKox99/0AZW9o/BEQVrJfhI+fIHMErnPyRa89/yRXUUr93q+tiN6zhoVV4wQ==",
"requires": {
"rust-result": "^1.0.0"
}
},
"safe-regex": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz",
@ -32936,6 +33151,11 @@
"prepend-http": "^2.0.0"
}
},
"url-toolkit": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/url-toolkit/-/url-toolkit-2.2.5.tgz",
"integrity": "sha512-mtN6xk+Nac+oyJ/PrI7tzfmomRVNFIWKUbG8jdYFt52hxbiReFAXIjYskvu64/dvuW71IcB7lV8l0HvZMac6Jg=="
},
"use": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",
@ -33118,6 +33338,54 @@
"extsprintf": "^1.2.0"
}
},
"video.js": {
"version": "7.21.1",
"resolved": "https://registry.npmjs.org/video.js/-/video.js-7.21.1.tgz",
"integrity": "sha512-AvHfr14ePDHCfW5Lx35BvXk7oIonxF6VGhSxocmTyqotkQpxwYdmt4tnQSV7MYzNrYHb0GI8tJMt20NDkCQrxg==",
"requires": {
"@babel/runtime": "^7.12.5",
"@videojs/http-streaming": "2.15.1",
"@videojs/vhs-utils": "^3.0.4",
"@videojs/xhr": "2.6.0",
"aes-decrypter": "3.1.3",
"global": "^4.4.0",
"keycode": "^2.2.0",
"m3u8-parser": "4.8.0",
"mpd-parser": "0.22.1",
"mux.js": "6.0.1",
"safe-json-parse": "4.0.0",
"videojs-font": "3.2.0",
"videojs-vtt.js": "^0.15.4"
},
"dependencies": {
"@babel/runtime": {
"version": "7.20.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.7.tgz",
"integrity": "sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ==",
"requires": {
"regenerator-runtime": "^0.13.11"
}
},
"regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="
}
}
},
"videojs-font": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/videojs-font/-/videojs-font-3.2.0.tgz",
"integrity": "sha512-g8vHMKK2/JGorSfqAZQUmYYNnXmfec4MLhwtEFS+mMs2IDY398GLysy6BH6K+aS1KMNu/xWZ8Sue/X/mdQPliA=="
},
"videojs-vtt.js": {
"version": "0.15.4",
"resolved": "https://registry.npmjs.org/videojs-vtt.js/-/videojs-vtt.js-0.15.4.tgz",
"integrity": "sha512-r6IhM325fcLb1D6pgsMkTQT1PpFdUdYZa1iqk7wJEu+QlibBwATPfPc9Bg8Jiym0GE5yP1AG2rMLu+QMVWkYtA==",
"requires": {
"global": "^4.3.1"
}
},
"vinyl": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/vinyl/-/vinyl-2.2.1.tgz",
@ -33213,6 +33481,11 @@
}
}
},
"vmsg": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/vmsg/-/vmsg-0.4.0.tgz",
"integrity": "sha512-46BBqRSfqdFGUpO2j+Hpz8T9YE5uWG0/PWal1PT+R1o8NEthtjG/XWl4HzbB8hIHpg/UtmKvsxL2OKQBrIYcHQ=="
},
"w3c-hr-time": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",

View File

@ -1,6 +1,6 @@
{
"name": "moodlemobile",
"version": "4.1.0",
"version": "4.1.1",
"description": "The official app for Moodle.",
"author": {
"name": "Moodle Pty Ltd.",
@ -24,7 +24,7 @@
"build": "ionic build",
"build:prod": "NODE_ENV=production ionic build --prod",
"build:test": "NODE_ENV=testing ionic build --configuration=testing",
"dev:android": "ionic cordova run android --livereload",
"dev:android": "ionic cordova run android --livereload --external --ssl",
"dev:ios": "ionic cordova run ios",
"prod:android": "NODE_ENV=production ionic cordova run android --prod",
"prod:ios": "NODE_ENV=production ionic cordova run ios --prod",
@ -63,7 +63,6 @@
"@ionic-native/ionic-webview": "5.36.0",
"@ionic-native/keyboard": "5.36.0",
"@ionic-native/local-notifications": "5.36.0",
"@ionic-native/media": "5.36.0",
"@ionic-native/media-capture": "5.36.0",
"@ionic-native/network": "5.36.0",
"@ionic-native/push": "5.36.0",
@ -73,7 +72,7 @@
"@ionic-native/status-bar": "5.36.0",
"@ionic-native/web-intent": "5.36.0",
"@ionic-native/zip": "5.36.0",
"@ionic/angular": "5.9.2",
"@ionic/angular": "5.9.4",
"@moodlehq/cordova-plugin-file-opener": "3.0.5-moodle.1",
"@moodlehq/cordova-plugin-file-transfer": "1.7.1-moodle.5",
"@moodlehq/cordova-plugin-inappbrowser": "5.0.0-moodle.3",
@ -104,7 +103,6 @@
"cordova-plugin-file": "6.0.2",
"cordova-plugin-geolocation": "4.1.0",
"cordova-plugin-ionic-keyboard": "2.2.0",
"cordova-plugin-media": "5.0.4",
"cordova-plugin-media-capture": "3.0.3",
"cordova-plugin-network-information": "3.0.0",
"cordova-plugin-prevent-override": "1.0.1",
@ -122,10 +120,13 @@
"mathjax": "2.7.9",
"moment": "2.29.4",
"moment-timezone": "0.5.38",
"mp3-mediarecorder": "^4.0.5",
"nl.kingsquare.cordova.background-audio": "1.0.1",
"ogv": "1.8.9",
"rxjs": "6.5.5",
"ts-md5": "1.2.7",
"tslib": "2.3.1",
"video.js": "7.21.1",
"zone.js": "0.10.3"
},
"devDependencies": {
@ -222,9 +223,6 @@
"ANDROID_SUPPORT_V4_VERSION": "26.+"
},
"cordova-plugin-media-capture": {},
"cordova-plugin-media": {
"KEEP_AVAUDIOSESSION_ALWAYS_ACTIVE": "NO"
},
"cordova-plugin-network-information": {},
"@moodlehq/cordova-plugin-qrscanner": {},
"cordova-plugin-splashscreen": {},

View File

@ -0,0 +1,30 @@
diff --git a/node_modules/event-target-shim/index.d.ts b/node_modules/event-target-shim/index.d.ts
index 7a5bfc7..ba5e7d8 100644
--- a/node_modules/event-target-shim/index.d.ts
+++ b/node_modules/event-target-shim/index.d.ts
@@ -359,7 +359,7 @@ export declare namespace defineCustomEventTarget {
/**
* The interface of CustomEventTarget.
*/
- type CustomEventTarget<TEventMap extends Record<string, Event>, TMode extends "standard" | "strict"> = EventTarget<TEventMap, TMode> & defineEventAttribute.EventAttributes<any, TEventMap>;
+ type CustomEventTarget<TEventMap extends Record<string, Event>, TMode extends "standard" | "strict"> = EventTarget<TEventMap, TMode> & defineEventAttribute.EventAttributes<any>;
}
/**
* Define an event attribute.
@@ -368,14 +368,12 @@ export declare namespace defineCustomEventTarget {
* @param _eventClass Unused, but to infer `Event` class type.
* @deprecated Use `getEventAttributeValue`/`setEventAttributeValue` pair on your derived class instead because of static analysis friendly.
*/
-export declare function defineEventAttribute<TEventTarget extends EventTarget, TEventType extends string, TEventConstrucor extends typeof Event>(target: TEventTarget, type: TEventType, _eventClass?: TEventConstrucor): asserts target is TEventTarget & defineEventAttribute.EventAttributes<TEventTarget, Record<TEventType, InstanceType<TEventConstrucor>>>;
+export declare function defineEventAttribute<TEventTarget extends EventTarget, TEventType extends string, TEventConstrucor extends typeof Event>(target: TEventTarget, type: TEventType, _eventClass?: TEventConstrucor): asserts target is TEventTarget & defineEventAttribute.EventAttributes<TEventTarget>;
export declare namespace defineEventAttribute {
/**
* Definition of event attributes.
*/
- type EventAttributes<TEventTarget extends EventTarget<any, any>, TEventMap extends Record<string, Event>> = {
- [P in string & keyof TEventMap as `on${P}`]: EventTarget.CallbackFunction<TEventTarget, TEventMap[P]> | null;
- };
+ type EventAttributes<TEventTarget extends EventTarget<any, any>> = Record<string, EventTarget.CallbackFunction<TEventTarget, any> | null>;
}
/**
* Set the warning handler.

View File

@ -0,0 +1,91 @@
diff --git a/node_modules/mp3-mediarecorder/dist/index.es.js b/node_modules/mp3-mediarecorder/dist/index.es.js
index 7a96961..82ec4e8 100644
--- a/node_modules/mp3-mediarecorder/dist/index.es.js
+++ b/node_modules/mp3-mediarecorder/dist/index.es.js
@@ -357,8 +357,7 @@ class Event$1 {
InitEventWasCalledWhileDispatching.warn();
return;
}
- internalDataMap.set(this, {
- ...data,
+ internalDataMap.set(this, Object.assign({}, data, {
type: String(type),
bubbles: Boolean(bubbles),
cancelable: Boolean(cancelable),
@@ -366,8 +365,8 @@ class Event$1 {
currentTarget: null,
stopPropagationFlag: false,
stopImmediatePropagationFlag: false,
- canceledFlag: false,
- });
+ canceledFlag: false
+ }));
}
}
//------------------------------------------------------------------------------
diff --git a/node_modules/mp3-mediarecorder/dist/index.es5.js b/node_modules/mp3-mediarecorder/dist/index.es5.js
index 0caa82d..aa46cc2 100644
--- a/node_modules/mp3-mediarecorder/dist/index.es5.js
+++ b/node_modules/mp3-mediarecorder/dist/index.es5.js
@@ -418,7 +418,7 @@ class Event$1 {
return;
}
- internalDataMap.set(this, { ...data,
+ internalDataMap.set(this, Object.assign({}, data, {
type: String(type),
bubbles: Boolean(bubbles),
cancelable: Boolean(cancelable),
@@ -427,7 +427,7 @@ class Event$1 {
stopPropagationFlag: false,
stopImmediatePropagationFlag: false,
canceledFlag: false
- });
+ }));
}
} //------------------------------------------------------------------------------
diff --git a/node_modules/mp3-mediarecorder/dist/index.js b/node_modules/mp3-mediarecorder/dist/index.js
index f7a517e..5f7f415 100644
--- a/node_modules/mp3-mediarecorder/dist/index.js
+++ b/node_modules/mp3-mediarecorder/dist/index.js
@@ -418,7 +418,7 @@ class Event$1 {
return;
}
- internalDataMap.set(this, { ...data,
+ internalDataMap.set(this, Object.assign({}, data, {
type: String(type),
bubbles: Boolean(bubbles),
cancelable: Boolean(cancelable),
@@ -427,7 +427,7 @@ class Event$1 {
stopPropagationFlag: false,
stopImmediatePropagationFlag: false,
canceledFlag: false
- });
+ }));
}
} //------------------------------------------------------------------------------
diff --git a/node_modules/mp3-mediarecorder/dist/index.umd.js b/node_modules/mp3-mediarecorder/dist/index.umd.js
index 3f5f2a2..dd7783d 100644
--- a/node_modules/mp3-mediarecorder/dist/index.umd.js
+++ b/node_modules/mp3-mediarecorder/dist/index.umd.js
@@ -418,7 +418,7 @@ class Event$1 {
return;
}
- internalDataMap.set(this, { ...data,
+ internalDataMap.set(this, Object.assign({}, data, {
type: String(type),
bubbles: Boolean(bubbles),
cancelable: Boolean(cancelable),
@@ -427,7 +427,7 @@ class Event$1 {
stopPropagationFlag: false,
stopImmediatePropagationFlag: false,
canceledFlag: false
- });
+ }));
}
} //------------------------------------------------------------------------------

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 57 KiB

View File

@ -76,37 +76,46 @@ async function main() {
};
writeFileSync(pluginFilePath, replaceArguments(fileContents, replacements));
// Copy feature files.
// Copy feature and snapshot files.
if (!excludeFeatures) {
const behatTempFeaturesPath = `${pluginPath}/behat-tmp`;
copySync(projectPath('src'), behatTempFeaturesPath, { filter: isFeatureFileOrDirectory });
copySync(projectPath('src'), behatTempFeaturesPath, { filter: shouldCopyFileOrDirectory });
const behatFeaturesPath = `${pluginPath}/tests/behat`;
if (!existsSync(behatFeaturesPath)) {
mkdirSync(behatFeaturesPath, {recursive: true});
}
for await (const featureFile of getDirectoryFiles(behatTempFeaturesPath)) {
const featurePath = dirname(featureFile);
if (!featurePath.endsWith('/tests/behat')) {
for await (const file of getDirectoryFiles(behatTempFeaturesPath)) {
const filePath = dirname(file);
if (filePath.endsWith('/tests/behat/snapshots')) {
renameSync(file, behatFeaturesPath + '/snapshots/' + basename(file));
continue;
}
const newPath = featurePath.substring(0, featurePath.length - ('/tests/behat'.length));
if (!filePath.endsWith('/tests/behat')) {
continue;
}
const newPath = filePath.substring(0, filePath.length - ('/tests/behat'.length));
const searchRegExp = /\//g;
const prefix = relative(behatTempFeaturesPath, newPath).replace(searchRegExp,'-') || 'core';
const featureFilename = prefix + '-' + basename(featureFile);
renameSync(featureFile, behatFeaturesPath + '/' + featureFilename);
const featureFilename = prefix + '-' + basename(file);
renameSync(file, behatFeaturesPath + '/' + featureFilename);
}
rmSync(behatTempFeaturesPath, {recursive: true});
}
}
function isFeatureFileOrDirectory(src) {
const stats = statSync(src);
function shouldCopyFileOrDirectory(path) {
const stats = statSync(path);
return stats.isDirectory() || extname(src) === '.feature';
return stats.isDirectory()
|| extname(path) === '.feature'
|| extname(path) === '.png';
}
function isExcluded(file, exclusions) {

View File

@ -27,7 +27,10 @@ const ASSETS = {
'/node_modules/mathjax/jax/output/SVG': '/lib/mathjax/jax/output/SVG',
'/node_modules/mathjax/jax/output/PreviewHTML': '/lib/mathjax/jax/output/PreviewHTML',
'/node_modules/mathjax/localization': '/lib/mathjax/localization',
'/node_modules/mp3-mediarecorder/dist/vmsg.wasm': '/lib/vmsg/vmsg.wasm',
'/src/core/features/h5p/assets': '/lib/h5p',
'/node_modules/ogv/dist': '/lib/ogv',
'/node_modules/video.js/dist/video-js.min.css': '/lib/video.js/video-js.min.css',
};
module.exports = function(ctx) {

View File

@ -1652,6 +1652,13 @@
"core.courses.totalcoursesearchresults": "local_moodlemobileapp",
"core.currentdevice": "local_moodlemobileapp",
"core.custom": "form",
"core.reportbuilder.modifiedby": "tool_reportbuilder",
"core.reportbuilder.reports": "moodle",
"core.reportbuilder.reportsource": "moodle",
"core.reportbuilder.timecreated": "moodle",
"core.reportbuilder.filtersapplied": "local_moodlemobileapp",
"core.reportbuilder.showcolumns": "local_moodlemobileapp",
"core.reportbuilder.hidecolumns": "local_moodlemobileapp",
"core.datastoredoffline": "local_moodlemobileapp",
"core.date": "moodle",
"core.datecreated": "repository",
@ -1735,9 +1742,11 @@
"core.filenotfound": "resource",
"core.fileuploader.addfiletext": "repository",
"core.fileuploader.audio": "local_moodlemobileapp",
"core.fileuploader.audiotitle": "tiny_recordrtc",
"core.fileuploader.camera": "local_moodlemobileapp",
"core.fileuploader.confirmuploadfile": "local_moodlemobileapp",
"core.fileuploader.confirmuploadunknownsize": "local_moodlemobileapp",
"core.fileuploader.discardrecording": "local_moodlemobileapp",
"core.fileuploader.errorcapturingaudio": "local_moodlemobileapp",
"core.fileuploader.errorcapturingimage": "local_moodlemobileapp",
"core.fileuploader.errorcapturingvideo": "local_moodlemobileapp",
@ -1751,11 +1760,18 @@
"core.fileuploader.fileuploaded": "local_moodlemobileapp",
"core.fileuploader.invalidfiletype": "repository",
"core.fileuploader.maxbytesfile": "local_moodlemobileapp",
"core.fileuploader.microphonepermissiondenied": "local_moodlemobileapp",
"core.fileuploader.microphonepermissionrestricted": "local_moodlemobileapp",
"core.fileuploader.more": "data",
"core.fileuploader.pauserecording": "local_moodlemobileapp",
"core.fileuploader.photoalbums": "local_moodlemobileapp",
"core.fileuploader.readingfile": "local_moodlemobileapp",
"core.fileuploader.readingfileperc": "local_moodlemobileapp",
"core.fileuploader.resumerecording": "local_moodlemobileapp",
"core.fileuploader.selectafile": "local_moodlemobileapp",
"core.fileuploader.startrecording": "tiny_recordrtc",
"core.fileuploader.startrecordinginstructions": "local_moodlemobileapp",
"core.fileuploader.stoprecording": "tiny_recordrtc",
"core.fileuploader.uploadafile": "local_moodlemobileapp",
"core.fileuploader.uploading": "local_moodlemobileapp",
"core.fileuploader.uploadingperc": "local_moodlemobileapp",
@ -2098,6 +2114,7 @@
"core.nopasswordchangeforced": "local_moodlemobileapp",
"core.nopermissionerror": "local_moodlemobileapp",
"core.nopermissions": "error",
"core.nopermissiontoaccesspage": "error",
"core.noresults": "moodle",
"core.noselection": "form",
"core.notapplicable": "local_moodlemobileapp",

View File

@ -33,7 +33,7 @@
</ion-item-divider>
<ion-item class="ion-text-wrap">
<ion-label>
<h2>{{ 'core.name' | translate}}</h2>
<p class="item-heading">{{ 'core.name' | translate}}</p>
<p>{{ user.fullname }}</p>
</ion-label>
</ion-item>
@ -48,13 +48,13 @@
</ion-item-divider>
<ion-item class="ion-text-wrap" *ngIf="badge.issuername">
<ion-label>
<h2>{{ 'addon.badges.issuername' | translate}}</h2>
<p class="item-heading">{{ 'addon.badges.issuername' | translate}}</p>
<p>{{ badge.issuername }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.issuercontact">
<ion-label>
<h2>{{ 'addon.badges.contact' | translate}}</h2>
<p class="item-heading">{{ 'addon.badges.contact' | translate}}</p>
<p><a href="mailto:{{badge.issuercontact}}" core-link auto-login="no" [showBrowserWarning]="false">
{{ badge.issuercontact }}
</a></p>
@ -70,37 +70,37 @@
</ion-item-divider>
<ion-item class="ion-text-wrap" *ngIf="badge.name">
<ion-label>
<h2>{{ 'core.name' | translate}}</h2>
<p class="item-heading">{{ 'core.name' | translate}}</p>
<p>{{ badge.name }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.version">
<ion-label>
<h2>{{ 'addon.badges.version' | translate}}</h2>
<p class="item-heading">{{ 'addon.badges.version' | translate}}</p>
<p>{{ badge.version }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.language">
<ion-label>
<h2>{{ 'addon.badges.language' | translate}}</h2>
<p class="item-heading">{{ 'addon.badges.language' | translate}}</p>
<p>{{ badge.language }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.description">
<ion-label>
<h2>{{ 'core.description' | translate}}</h2>
<p class="item-heading">{{ 'core.description' | translate}}</p>
<p>{{ badge.description }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.imageauthorname">
<ion-label>
<h2>{{ 'addon.badges.imageauthorname' | translate}}</h2>
<p class="item-heading">{{ 'addon.badges.imageauthorname' | translate}}</p>
<p>{{ badge.imageauthorname }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.imageauthoremail">
<ion-label>
<h2>{{ 'addon.badges.imageauthoremail' | translate}}</h2>
<p class="item-heading">{{ 'addon.badges.imageauthoremail' | translate}}</p>
<p><a href="mailto:{{badge.imageauthoremail}}" core-link auto-login="no" [showBrowserWarning]="false">
{{ badge.imageauthoremail }}
</a></p>
@ -108,19 +108,19 @@
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.imageauthorurl">
<ion-label>
<h2>{{ 'addon.badges.imageauthorurl' | translate}}</h2>
<p class="item-heading">{{ 'addon.badges.imageauthorurl' | translate}}</p>
<p><a [href]="badge.imageauthorurl" core-link auto-login="no"> {{ badge.imageauthorurl }} </a></p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.imagecaption">
<ion-label>
<h2>{{ 'addon.badges.imagecaption' | translate}}</h2>
<p class="item-heading">{{ 'addon.badges.imagecaption' | translate}}</p>
<p>{{ badge.imagecaption }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="course">
<ion-label>
<h2>{{ 'core.course' | translate}}</h2>
<p class="item-heading">{{ 'core.course' | translate}}</p>
<p>
<core-format-text [text]="course.fullname" contextLevel="course" [contextInstanceId]="courseId">
</core-format-text>
@ -138,13 +138,13 @@
</ion-item-divider>
<ion-item class="ion-text-wrap" *ngIf="badge.dateissued">
<ion-label>
<h2>{{ 'addon.badges.dateawarded' | translate}}</h2>
<p class="item-heading">{{ 'addon.badges.dateawarded' | translate}}</p>
<p>{{badge.dateissued * 1000 | coreFormatDate }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.dateexpire">
<ion-label>
<h2>{{ 'addon.badges.expirydate' | translate}}</h2>
<p class="item-heading">{{ 'addon.badges.expirydate' | translate}}</p>
<p>
{{ badge.dateexpire * 1000 | coreFormatDate }}
<span class="text-danger" *ngIf="currentTime >= badge.dateexpire">
@ -165,13 +165,13 @@
</ion-item-divider>
<ion-item class="ion-text-wrap" *ngIf="badge.endorsement.issuername">
<ion-label>
<h2>{{ 'addon.badges.issuername' | translate}}</h2>
<p class="item-heading">{{ 'addon.badges.issuername' | translate}}</p>
<p>{{ badge.endorsement.issuername }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.endorsement.issueremail">
<ion-label>
<h2>{{ 'addon.badges.issueremail' | translate}}</h2>
<p class="item-heading">{{ 'addon.badges.issueremail' | translate}}</p>
<p>
<a href="mailto:{{badge.endorsement.issueremail}}" core-link auto-login="no" [showBrowserWarning]="false">
{{ badge.endorsement.issueremail }}
@ -181,25 +181,25 @@
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.endorsement.issuerurl">
<ion-label>
<h2>{{ 'addon.badges.issuerurl' | translate}}</h2>
<p class="item-heading">{{ 'addon.badges.issuerurl' | translate}}</p>
<p><a [href]="badge.endorsement.issuerurl" core-link auto-login="no"> {{ badge.endorsement.issuerurl }} </a></p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.endorsement.dateissued">
<ion-label>
<h2>{{ 'addon.badges.dateawarded' | translate}}</h2>
<p class="item-heading">{{ 'addon.badges.dateawarded' | translate}}</p>
<p>{{ badge.endorsement.dateissued * 1000 | coreFormatDate }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.endorsement.claimid">
<ion-label>
<h2>{{ 'addon.badges.claimid' | translate}}</h2>
<p class="item-heading">{{ 'addon.badges.claimid' | translate}}</p>
<p><a [href]="badge.endorsement.claimid" core-link auto-login="no"> {{ badge.endorsement.claimid }} </a></p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.endorsement.claimcomment">
<ion-label>
<h2>{{ 'addon.badges.claimcomment' | translate}}</h2>
<p class="item-heading">{{ 'addon.badges.claimcomment' | translate}}</p>
<p>{{ badge.endorsement.claimcomment }}</p>
</ion-label>
</ion-item>
@ -214,12 +214,12 @@
</ion-item-divider>
<ion-item class="ion-text-wrap" *ngFor="let relatedBadge of badge.relatedbadges">
<ion-label>
<h2>{{ relatedBadge.name }}</h2>
<p class="item-heading">{{ relatedBadge.name }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.relatedbadges.length == 0">
<ion-label>
<h2>{{ 'addon.badges.norelated' | translate}}</h2>
<p class="item-heading">{{ 'addon.badges.norelated' | translate}}</p>
</ion-label>
</ion-item>
</ion-item-group>
@ -234,12 +234,12 @@
<ion-item class="ion-text-wrap" *ngFor="let alignment of badge.alignment" [href]="alignment.targeturl" core-link
auto-login="no">
<ion-label>
<h2>{{ alignment.targetname }}</h2>
<p class="item-heading">{{ alignment.targetname }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.alignment.length == 0">
<ion-label>
<h2>{{ 'addon.badges.noalignment' | translate}}</h2>
<p class="item-heading">{{ 'addon.badges.noalignment' | translate}}</p>
</ion-label>
</ion-item>
</ion-item-group>

View File

@ -25,11 +25,11 @@ Feature: Timeline block.
| assign | C1 | assign03 | Assignment 03 | ##tomorrow## |
| assign | C2 | assign04 | Assignment 04 | ##+2 days## |
| assign | C1 | assign05 | Assignment 05 | ##+5 days## |
| assign | C2 | assign06 | Assignment 06 | ##+1 month## |
| assign | C2 | assign07 | Assignment 07 | ##+1 month## |
| assign | C3 | assign08 | Assignment 08 | ##+1 month## |
| assign | C2 | assign09 | Assignment 09 | ##+1 month## |
| assign | C1 | assign10 | Assignment 10 | ##+1 month## |
| assign | C2 | assign06 | Assignment 06 | ##+31 days## |
| assign | C2 | assign07 | Assignment 07 | ##+31 days## |
| assign | C3 | assign08 | Assignment 08 | ##+31 days## |
| assign | C2 | assign09 | Assignment 09 | ##+31 days## |
| assign | C1 | assign10 | Assignment 10 | ##+31 days## |
| assign | C1 | assign11 | Assignment 11 | ##+6 months## |
| assign | C1 | assign12 | Assignment 12 | ##+6 months## |
| assign | C1 | assign13 | Assignment 13 | ##+6 months## |

View File

@ -25,11 +25,11 @@ Feature: Timeline block.
| assign | C1 | assign03 | Assignment 03 | ##tomorrow## |
| assign | C2 | assign04 | Assignment 04 | ##+2 days## |
| assign | C1 | assign05 | Assignment 05 | ##+5 days## |
| assign | C2 | assign06 | Assignment 06 | ##+1 month## |
| assign | C2 | assign07 | Assignment 07 | ##+1 month## |
| assign | C3 | assign08 | Assignment 08 | ##+1 month## |
| assign | C2 | assign09 | Assignment 09 | ##+1 month## |
| assign | C1 | assign10 | Assignment 10 | ##+1 month## |
| assign | C2 | assign06 | Assignment 06 | ##+31 days## |
| assign | C2 | assign07 | Assignment 07 | ##+31 days## |
| assign | C3 | assign08 | Assignment 08 | ##+31 days## |
| assign | C2 | assign09 | Assignment 09 | ##+31 days## |
| assign | C1 | assign10 | Assignment 10 | ##+31 days## |
| assign | C1 | assign11 | Assignment 11 | ##+6 months## |
| assign | C1 | assign12 | Assignment 12 | ##+6 months## |
| assign | C1 | assign13 | Assignment 13 | ##+6 months## |

View File

@ -27,19 +27,22 @@
<ion-item class="ion-text-wrap">
<core-user-avatar [user]="entry.user" slot="start" [courseId]="entry.courseid"></core-user-avatar>
<ion-label>
<p class="item-heading">
<core-format-text [text]="entry.subject" [contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId">
</core-format-text>
<ion-note class="ion-float-end ion-padding-start ion-text-end">
<div class="flex-row ion-justify-content-between ion-align-items-center">
<h2>
<core-format-text [text]="entry.subject" [contextLevel]="contextLevel"
[contextInstanceId]="contextInstanceId">
</core-format-text>
</h2>
<ion-note class="ion-text-end">
{{ 'addon.blog.' + entry.publishTranslated! | translate}}
</ion-note>
</p>
<p>
<ion-note class="ion-float-end ion-text-end">
</div>
<div class="flex-row ion-justify-content-between ion-align-items-center">
{{entry.user && entry.user.fullname}}
<ion-note class="ion-text-end">
{{entry.created | coreDateDayOrTime}}
</ion-note>
{{entry.user && entry.user!.fullname}}
</p>
</div>
</ion-label>
</ion-item>
<ion-card-content>

View File

@ -33,7 +33,7 @@
</ion-grid>
<core-swipe-slides [manager]="manager">
<ng-template let-month="item">
<ng-template let-month="item" let-activeView="active">
<!-- Calendar view. -->
<ion-grid class="addon-calendar-months" role="table" aria-describedby="addon-calendar-monthname">
<div role="rowgroup">
@ -57,9 +57,9 @@
"today": month.isCurrentMonth && day.istoday,
"weekend": day.isweekend,
"duration_finish": day.haslastdayofevent
}' [class.addon-calendar-event-past-day]="month.isPastMonth || day.ispast" role="cell" tabindex="0"
(ariaButtonClick)="dayClicked(day.mday)">
<p class="addon-calendar-day-number" role="button">
}' [class.addon-calendar-event-past-day]="month.isPastMonth || day.ispast" role="cell"
(ariaButtonClick)="dayClicked(day.mday)" [tabindex]="activeView ? 0 : -1">
<p class="addon-calendar-day-number">
<span aria-hidden="true">{{ day.mday }}</span>
<span class="sr-only">{{ day.periodName | translate }}</span>
</p>
@ -72,8 +72,8 @@
<div class="ion-hide-md-down addon-calendar-day-events" *ngIf="day.filteredEvents">
<ng-container *ngFor="let event of day.filteredEvents | slice:0:4; let index = index">
<div *ngIf="index < 3 || day.filteredEvents.length == 4" class="addon-calendar-event"
[class.addon-calendar-event-past]="event.ispast" role="button" tabindex="0"
(ariaButtonClick)="eventClicked(event, $event)">
[class.addon-calendar-event-past]="event.ispast" (ariaButtonClick)="eventClicked(event, $event)"
[tabindex]="activeView ? 0 : -1">
<span class="calendar_event_type calendar_event_{{event.formattedType}}"></span>
<ion-icon *ngIf="event.offline && !event.deleted" name="fas-clock"
[attr.aria-label]="'core.notsent' | translate"></ion-icon>

View File

@ -25,7 +25,6 @@
@include border-end(1px, solid var(--addon-calendar-border-color));
overflow: hidden;
min-height: 60px;
cursor: pointer;
&:first-child {
@include padding-horizontal(10px, null);
@ -99,7 +98,7 @@
.addon-calendar-period {
flex-grow: 3;
h3 {
h2 {
margin-top: 10px;
font-size: 1.2rem;
}

View File

@ -38,7 +38,7 @@
</ion-button>
</ion-col>
<ion-col class="ion-text-center addon-calendar-period">
<h3>{{ periodName }}</h3>
<h2>{{ periodName }}</h2>
</ion-col>
<ion-col class="ion-text-end">
<ion-button fill="clear" (click)="loadNext()" [attr.aria-label]="'addon.calendar.daynext' | translate">

View File

@ -6,7 +6,7 @@
.addon-calendar-period {
flex-grow: 3;
h3 {
h2 {
margin-top: 10px;
font-size: 1.2rem;
}

View File

@ -60,7 +60,7 @@
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
<h2>{{ 'addon.calendar.when' | translate }}</h2>
<p class="item-heading">{{ 'addon.calendar.when' | translate }}</p>
<core-format-text [text]="event.formattedtime" [contextLevel]="event.contextLevel"
[contextInstanceId]="event.contextInstanceId"></core-format-text>
</ion-label>
@ -70,13 +70,13 @@
</ion-item>
<ion-item>
<ion-label>
<h2>{{ 'addon.calendar.eventtype' | translate }}</h2>
<p class="item-heading">{{ 'addon.calendar.eventtype' | translate }}</p>
<p>{{ 'addon.calendar.type' + event.formattedType | translate }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="courseName" [href]="courseUrl" core-link capture="true">
<ion-label>
<h2>{{ 'core.course' | translate}}</h2>
<p class="item-heading">{{ 'core.course' | translate}}</p>
<p>
<core-format-text [text]="courseName" contextLevel="course" [contextInstanceId]="courseId">
</core-format-text>
@ -85,13 +85,13 @@
</ion-item>
<ion-item class="ion-text-wrap core-groupname" *ngIf="groupName">
<ion-label>
<h2>{{ 'core.group' | translate}}</h2>
<p class="item-heading">{{ 'core.group' | translate}}</p>
<p>{{ groupName }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="categoryPath">
<ion-label>
<h2>{{ 'core.category' | translate}}</h2>
<p class="item-heading">{{ 'core.category' | translate}}</p>
<p>
<core-format-text [text]="categoryPath" contextLevel="coursecat" [contextInstanceId]="event.categoryid">
</core-format-text>
@ -100,7 +100,7 @@
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="event.description">
<ion-label>
<h2>{{ 'core.description' | translate}}</h2>
<p class="item-heading">{{ 'core.description' | translate}}</p>
<p>
<core-format-text [text]="event.description" [contextLevel]="event.contextLevel"
[contextInstanceId]="event.contextInstanceId"></core-format-text>
@ -109,7 +109,7 @@
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="event.location">
<ion-label>
<h2>{{ 'core.location' | translate}}</h2>
<p class="item-heading">{{ 'core.location' | translate}}</p>
<p>
<a [href]="event.encodedLocation" core-link auto-login="no">
<core-format-text [text]="event.location" [contextLevel]="event.contextLevel"

View File

@ -19,7 +19,7 @@
<ion-item class="ion-text-wrap">
<core-user-avatar [user]="user" slot="start"></core-user-avatar>
<ion-label>
<h2>{{ user.fullname }}</h2>
<p class="item-heading">{{ user.fullname }}</p>
</ion-label>
</ion-item>
</ion-card>
@ -115,7 +115,7 @@
</ion-card>
<div *ngIf="competency">
<h3 class="ion-margin-horizontal">{{ 'addon.competency.evidence' | translate }}</h3>
<h2 class="ion-margin-horizontal">{{ 'addon.competency.evidence' | translate }}</h2>
<p class="ion-margin-horizontal" *ngIf="competency.evidence.length == 0">
{{ 'addon.competency.noevidence' | translate }}
</p>

View File

@ -53,7 +53,7 @@
<ion-item class="ion-text-wrap">
<core-user-avatar [user]="user" slot="start"></core-user-avatar>
<ion-label>
<h2>{{ user.fullname }}</h2>
<p class="item-heading">{{ user.fullname }}</p>
</ion-label>
</ion-item>
</ion-card>

View File

@ -17,7 +17,7 @@
<ion-item class="ion-text-wrap">
<ion-label>
<core-user-avatar [user]="user" slot="start"></core-user-avatar>
<h2>{{ user.fullname }}</h2>
<p class="item-heading">{{ user.fullname }}</p>
</ion-label>
</ion-item>
</ion-card>

View File

@ -16,20 +16,20 @@
<ion-item class="ion-text-wrap" *ngIf="user">
<core-user-avatar [user]="user" [courseId]="courseId" slot="start" [linkProfile]="false"></core-user-avatar>
<ion-label>
<h2>{{user!.fullname}}</h2>
<p class="item-heading">{{user.fullname}}</p>
</ion-label>
</ion-item>
<ion-card *ngIf="completion && tracked">
<ion-item class="ion-text-wrap">
<ion-label>
<h2>{{ 'addon.coursecompletion.status' | translate }}</h2>
<p class="item-heading">{{ 'addon.coursecompletion.status' | translate }}</p>
<p>{{ statusText! | translate }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
<h2>{{ 'addon.coursecompletion.required' | translate }}</h2>
<p class="item-heading">{{ 'addon.coursecompletion.required' | translate }}</p>
<p *ngIf="completion.aggregation === 1">{{ 'addon.coursecompletion.criteriarequiredall' | translate }}</p>
<p *ngIf="completion.aggregation === 2">{{ 'addon.coursecompletion.criteriarequiredany' | translate }}</p>
</ion-label>

View File

@ -0,0 +1,741 @@
// (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 { CorePlatform } from '@services/platform';
import { OGVPlayer, OGVCompat, OGVLoader } from 'ogv';
import videojs, { PreloadOption, TechSourceObject, VideoJSOptions } from 'video.js';
const Tech = videojs.getComponent('Tech');
/**
* Object.defineProperty but "lazy", which means that the value is only set after
* it retrieved the first time, rather than being set right away.
*
* @param obj The object to set the property on.
* @param key The key for the property to set.
* @param getValue The function used to get the value when it is needed.
* @param setter Whether a setter should be allowed or not.
* @returns Object.
*/
const defineLazyProperty = <T>(obj: T, key: string, getValue: () => unknown, setter = true): T => {
const set = (value: unknown): void => {
Object.defineProperty(obj, key, { value, enumerable: true, writable: true });
};
const options: PropertyDescriptor = {
configurable: true,
enumerable: true,
get() {
const value = getValue();
set(value);
return value;
},
};
if (setter) {
options.set = set;
}
return Object.defineProperty(obj, key, options);
};
/**
* OgvJS Media Controller for VideoJS - Wrapper for ogv.js Media API.
*
* Code adapted from https://github.com/HuongNV13/videojs-ogvjs/blob/f9b12bd53018d967bb305f02725834a98f20f61f/src/plugin.js
* Modified in the following ways:
* - Adapted to Typescript.
* - Use our own functions to detect the platform instead of using getDeviceOS.
* - Add an initialize static function.
* - In the play function, reset the media if it already ended to fix problems with replaying media.
* - Allow full screen in iOS devices, and implement enterFullScreen and exitFullScreen to use a fake full screen.
*/
export class VideoJSOgvJS extends Tech {
/**
* List of available events of the media player.
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
static readonly Events = [
'loadstart',
'suspend',
'abort',
'error',
'emptied',
'stalled',
'loadedmetadata',
'loadeddata',
'canplay',
'canplaythrough',
'playing',
'waiting',
'seeking',
'seeked',
'ended',
'durationchange',
'timeupdate',
'progress',
'play',
'pause',
'ratechange',
'resize',
'volumechange',
];
protected playerId?: string;
protected parentElement: HTMLElement | null = null;
protected placeholderElement = document.createElement('div');
// Variables/functions defined in parent classes.
protected el_!: OGVPlayerEl; // eslint-disable-line @typescript-eslint/naming-convention
protected options_!: VideoJSOptions; // eslint-disable-line @typescript-eslint/naming-convention
protected currentSource_?: TechSourceObject; // eslint-disable-line @typescript-eslint/naming-convention
protected triggerReady!: () => void;
protected on!: (name: string, callback: (e?: Event) => void) => void;
/**
* Create an instance of this Tech.
*
* @param options The key/value store of player options.
* @param ready Callback function to call when the `OgvJS` Tech is ready.
*/
constructor(options: VideoJSTechOptions, ready: () => void) {
super(options, ready);
this.el_.src = options.src || options.source?.src || options.sources?.[0]?.src || this.el_.src;
VideoJSOgvJS.setIfAvailable(this.el_, 'autoplay', options.autoplay);
VideoJSOgvJS.setIfAvailable(this.el_, 'loop', options.loop);
VideoJSOgvJS.setIfAvailable(this.el_, 'poster', options.poster);
VideoJSOgvJS.setIfAvailable(this.el_, 'preload', options.preload);
this.playerId = options.playerId;
this.on('loadedmetadata', () => {
if (CorePlatform.isIPhone()) {
// iPhoneOS add some inline styles to the canvas, we need to remove it.
const canvas = this.el_.getElementsByTagName('canvas')[0];
canvas.style.removeProperty('width');
canvas.style.removeProperty('margin');
}
this.triggerReady();
});
}
/**
* Set the value for the player is it has that property.
*
* @param el HTML player.
* @param name Name of the property.
* @param value Value to set.
*/
static setIfAvailable(el: HTMLElement, name: string, value: unknown): void {
// eslint-disable-next-line no-prototype-builtins
if (el.hasOwnProperty(name)) {
el[name] = value;
}
};
/**
* Check if browser/device is supported by Ogv.JS.
*
* @returns Whether it's supported.
*/
static isSupported(): boolean {
return OGVCompat.supported('OGVPlayer');
};
/**
* Check if the tech can support the given type.
*
* @param type The mimetype to check.
* @returns 'probably', 'maybe', or '' (empty string).
*/
static canPlayType(type: string): string {
return (type.indexOf('/ogg') !== -1 || type.indexOf('/webm')) ? 'maybe' : '';
};
/**
* Check if the tech can support the given source.
*
* @param srcObj The source object.
* @returns The options passed to the tech.
*/
static canPlaySource(srcObj: TechSourceObject): string {
return VideoJSOgvJS.canPlayType(srcObj.type);
};
/**
* Check if the volume can be changed in this browser/device.
* Volume cannot be changed in a lot of mobile devices.
* Specifically, it can't be changed from 1 on iOS.
*
* @returns True if volume can be controlled.
*/
static canControlVolume(): boolean {
if (CorePlatform.isIPhone()) {
return false;
}
const player = new OGVPlayer();
// eslint-disable-next-line no-prototype-builtins
return player.hasOwnProperty('volume');
};
/**
* Check if the volume can be muted in this browser/device.
*
* @returns True if volume can be muted.
*/
static canMuteVolume(): boolean {
return true;
};
/**
* Check if the playback rate can be changed in this browser/device.
*
* @returns True if playback rate can be controlled.
*/
static canControlPlaybackRate(): boolean {
return true;
};
/**
* Check to see if native 'TextTracks' are supported by this browser/device.
*
* @returns True if native 'TextTracks' are supported.
*/
static supportsNativeTextTracks(): boolean {
return false;
};
/**
* Check if the fullscreen resize is supported by this browser/device.
*
* @returns True if the fullscreen resize is supported.
*/
static supportsFullscreenResize(): boolean {
return true;
};
/**
* Check if the progress events is supported by this browser/device.
*
* @returns True if the progress events is supported.
*/
static supportsProgressEvents(): boolean {
return true;
};
/**
* Check if the time update events is supported by this browser/device.
*
* @returns True if the time update events is supported.
*/
static supportsTimeupdateEvents(): boolean {
return true;
};
/**
* Create the 'OgvJS' Tech's DOM element.
*
* @returns The element that gets created.
*/
createEl(): OGVPlayerEl {
const options = this.options_;
if (options.base) {
OGVLoader.base = options.base;
} else if (!OGVLoader.base) {
throw new Error('Please specify the base for the ogv.js library');
}
const el = new OGVPlayer(options);
el.className += ' vjs-tech';
options.tag = el;
return el;
}
/**
* Start playback.
*/
play(): void {
if (this.ended()) {
// Reset the player, otherwise the Replay button doesn't work.
this.el_.stop();
}
this.el_.play();
}
/**
* Get the current playback speed.
*
* @returns Playback speed.
*/
playbackRate(): number {
return this.el_.playbackRate || 1;
}
/**
* Set the playback speed.
*
* @param val Speed for the player to play.
*/
setPlaybackRate(val: number): void {
// eslint-disable-next-line no-prototype-builtins
if (this.el_.hasOwnProperty('playbackRate')) {
this.el_.playbackRate = val;
}
}
/**
* Returns a TimeRanges object that represents the ranges of the media resource that the user agent has played.
*
* @returns The range of points on the media timeline that has been reached through normal playback.
*/
played(): TimeRanges {
return this.el_.played;
}
/**
* Pause playback.
*/
pause(): void {
this.el_.pause();
}
/**
* Is the player paused or not.
*
* @returns Whether is paused.
*/
paused(): boolean {
return this.el_.paused;
}
/**
* Get current playing time.
*
* @returns Current time.
*/
currentTime(): number {
return this.el_.currentTime;
}
/**
* Set current playing time.
*
* @param seconds Current time of audio/video.
*/
setCurrentTime(seconds: number): void {
try {
this.el_.currentTime = seconds;
} catch (e) {
videojs.log(e, 'Media is not ready. (Video.JS)');
}
}
/**
* Get media's duration.
*
* @returns Duration.
*/
duration(): number {
if (this.el_.duration && this.el_.duration !== Infinity) {
return this.el_.duration;
}
return 0;
}
/**
* Get a TimeRange object that represents the intersection
* of the time ranges for which the user agent has all
* relevant media.
*
* @returns Time ranges.
*/
buffered(): TimeRanges {
return this.el_.buffered;
}
/**
* Get current volume level.
*
* @returns Volume.
*/
volume(): number {
// eslint-disable-next-line no-prototype-builtins
return this.el_.hasOwnProperty('volume') ? this.el_.volume : 1;
}
/**
* Set current playing volume level.
*
* @param percentAsDecimal Volume percent as a decimal.
*/
setVolume(percentAsDecimal: number): void {
// eslint-disable-next-line no-prototype-builtins
if (!CorePlatform.isIPhone() && this.el_.hasOwnProperty('volume')) {
this.el_.volume = percentAsDecimal;
}
}
/**
* Is the player muted or not.
*
* @returns Whether it's muted.
*/
muted(): boolean {
return this.el_.muted;
}
/**
* Mute the player.
*
* @param muted True to mute the player.
*/
setMuted(muted: boolean): void {
this.el_.muted = !!muted;
}
/**
* Is the player muted by default or not.
*
* @returns Whether it's muted by default.
*/
defaultMuted(): boolean {
return this.el_.defaultMuted || false;
}
/**
* Get the player width.
*
* @returns Width.
*/
width(): number {
return this.el_.offsetWidth;
}
/**
* Get the player height.
*
* @returns Height.
*/
height(): number {
return this.el_.offsetHeight;
}
/**
* Get the video width.
*
* @returns Video width.
*/
videoWidth(): number {
return (<HTMLVideoElement> this.el_).videoWidth ?? 0;
}
/**
* Get the video height.
*
* @returns Video heigth.
*/
videoHeight(): number {
return (<HTMLVideoElement> this.el_).videoHeight ?? 0;
}
/**
* Get/set media source.
*
* @param src Source.
* @returns Source when getting it, undefined when setting it.
*/
src(src?: string): string | undefined {
if (typeof src === 'undefined') {
return this.el_.src;
}
this.el_.src = src;
}
/**
* Load the media into the player.
*/
load(): void {
this.el_.load();
}
/**
* Get current media source.
*
* @returns Current source.
*/
currentSrc(): string {
if (this.currentSource_) {
return this.currentSource_.src;
}
return this.el_.currentSrc;
}
/**
* Get media poster URL.
*
* @returns Poster.
*/
poster(): string {
return 'poster' in this.el_ ? this.el_.poster : '';
}
/**
* Set media poster URL.
*
* @param url The poster image's url.
*/
setPoster(url: string): void {
(<HTMLVideoElement> this.el_).poster = url;
}
/**
* Is the media preloaded or not.
*
* @returns Whether it's preloaded.
*/
preload(): PreloadOption {
return <PreloadOption> this.el_.preload || 'none';
}
/**
* Set the media preload method.
*
* @param val Value for preload attribute.
*/
setPreload(val: PreloadOption): void {
// eslint-disable-next-line no-prototype-builtins
if (this.el_.hasOwnProperty('preload')) {
this.el_.preload = val;
}
}
/**
* Is the media auto-played or not.
*
* @returns Whether it's auto-played.
*/
autoplay(): boolean {
return this.el_.autoplay || false;
}
/**
* Set media autoplay method.
*
* @param val Value for autoplay attribute.
*/
setAutoplay(val: boolean): void {
// eslint-disable-next-line no-prototype-builtins
if (this.el_.hasOwnProperty('autoplay')) {
this.el_.autoplay = !!val;
}
}
/**
* Does the media has controls or not.
*
* @returns Whether it has controls.
*/
controls(): boolean {
return this.el_.controls || false;
}
/**
* Set the media controls method.
*
* @param val Value for controls attribute.
*/
setControls(val: boolean): void {
// eslint-disable-next-line no-prototype-builtins
if (this.el_.hasOwnProperty('controls')) {
this.el_.controls = !!val;
}
}
/**
* Is the media looped or not.
*
* @returns Whether it's looped.
*/
loop(): boolean {
return this.el_.loop || false;
}
/**
* Set the media loop method.
*
* @param val Value for loop attribute.
*/
setLoop(val: boolean): void {
// eslint-disable-next-line no-prototype-builtins
if (this.el_.hasOwnProperty('loop')) {
this.el_.loop = !!val;
}
}
/**
* Get a TimeRanges object that represents the
* ranges of the media resource to which it is possible
* for the user agent to seek.
*
* @returns Time ranges.
*/
seekable(): TimeRanges {
return this.el_.seekable;
}
/**
* Is player in the "seeking" state or not.
*
* @returns Whether is in the seeking state.
*/
seeking(): boolean {
return this.el_.seeking;
}
/**
* Is the media ended or not.
*
* @returns Whether it's ended.
*/
ended(): boolean {
return this.el_.ended;
}
/**
* Get the current state of network activity
* NETWORK_EMPTY (numeric value 0)
* NETWORK_IDLE (numeric value 1)
* NETWORK_LOADING (numeric value 2)
* NETWORK_NO_SOURCE (numeric value 3)
*
* @returns Network state.
*/
networkState(): number {
return this.el_.networkState;
}
/**
* Get the current state of the player.
* HAVE_NOTHING (numeric value 0)
* HAVE_METADATA (numeric value 1)
* HAVE_CURRENT_DATA (numeric value 2)
* HAVE_FUTURE_DATA (numeric value 3)
* HAVE_ENOUGH_DATA (numeric value 4)
*
* @returns Ready state.
*/
readyState(): number {
return this.el_.readyState;
}
/**
* Does the player support native fullscreen mode or not. (Mobile devices)
*
* @returns Whether it supports full screen.
*/
supportsFullScreen(): boolean {
return !!this.playerId;
}
/**
* Get media player error.
*
* @returns Error.
*/
error(): MediaError | null {
return this.el_.error;
}
/**
* Enter full screen mode.
*/
enterFullScreen(): void {
// Use a "fake" full screen mode, moving the player to a different place in DOM to be able to use full screen size.
const player = videojs.getPlayer(this.playerId ?? '');
if (!player) {
return;
}
const container = player.el();
this.parentElement = container.parentElement;
if (!this.parentElement) {
// Shouldn't happen, it means the element is not in DOM. Do not support full screen in this case.
return;
}
this.parentElement.replaceChild(this.placeholderElement, container);
document.body.appendChild(container);
container.classList.add('vjs-ios-moodleapp-fs');
player.isFullscreen(true);
}
/**
* Exit full screen mode.
*/
exitFullScreen(): void {
if (!this.parentElement) {
return;
}
const player = videojs.getPlayer(this.playerId ?? '');
if (!player) {
return;
}
const container = player.el();
this.parentElement.replaceChild(container, this.placeholderElement);
container.classList.remove('vjs-ios-moodleapp-fs');
player.isFullscreen(false);
}
}
[
['featuresVolumeControl', 'canControlVolume'],
['featuresMuteControl', 'canMuteVolume'],
['featuresPlaybackRate', 'canControlPlaybackRate'],
['featuresNativeTextTracks', 'supportsNativeTextTracks'],
['featuresFullscreenResize', 'supportsFullscreenResize'],
['featuresProgressEvents', 'supportsProgressEvents'],
['featuresTimeupdateEvents', 'supportsTimeupdateEvents'],
].forEach(([key, fn]) => {
defineLazyProperty(VideoJSOgvJS.prototype, key, () => VideoJSOgvJS[fn](), true);
});
type OGVPlayerEl = (HTMLAudioElement | HTMLVideoElement) & {
stop: () => void;
};
/**
* VideoJS Tech options. It includes some options added by VideoJS internally.
*/
type VideoJSTechOptions = VideoJSOptions & {
playerId?: string;
};

View File

@ -26,7 +26,9 @@ import { AddonFilterMediaPluginHandler } from './services/handlers/mediaplugin';
{
provide: APP_INITIALIZER,
multi: true,
useValue: () => CoreFilterDelegate.registerHandler(AddonFilterMediaPluginHandler.instance),
useValue: () => {
CoreFilterDelegate.registerHandler(AddonFilterMediaPluginHandler.instance);
},
},
],
})

View File

@ -12,12 +12,12 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { AddonFilterMediaPluginVideoJS } from '@addons/filter/mediaplugin/services/videojs';
import { Injectable } from '@angular/core';
import { CoreFilterDefaultHandler } from '@features/filter/services/handlers/default-filter';
import { CoreTextUtils } from '@services/utils/text';
import { CoreUrlUtils } from '@services/utils/url';
import { makeSingleton } from '@singletons';
import { CoreMedia } from '@singletons/media';
/**
* Handler to support the Multimedia filter.
@ -33,58 +33,38 @@ export class AddonFilterMediaPluginHandlerService extends CoreFilterDefaultHandl
/**
* @inheritdoc
*/
filter(
text: string,
): string | Promise<string> {
filter(text: string): string | Promise<string> {
this.template.innerHTML = text;
const videos = Array.from(this.template.content.querySelectorAll('video'));
videos.forEach((video) => {
this.treatVideoFilters(video);
AddonFilterMediaPluginVideoJS.treatYoutubeVideos(video);
});
return this.template.innerHTML;
}
/**
* Treat video filters. Currently only treating youtube video using video JS.
*
* @param video Video element.
* @inheritdoc
*/
protected treatVideoFilters(video: HTMLElement): void {
// Treat Video JS Youtube video links and translate them to iframes.
if (!video.classList.contains('video-js')) {
return;
}
handleHtml(container: HTMLElement): void {
const mediaElements = Array.from(container.querySelectorAll<HTMLVideoElement | HTMLAudioElement>('video, audio'));
const dataSetupString = video.getAttribute('data-setup') || video.getAttribute('data-setup-lazy') || '{}';
const data = <VideoDataSetup> CoreTextUtils.parseJSON(dataSetupString, {});
const youtubeUrl = data.techOrder?.[0] == 'youtube' && CoreUrlUtils.getYoutubeEmbedUrl(data.sources?.[0]?.src);
mediaElements.forEach((mediaElement) => {
if (CoreMedia.mediaUsesJavascriptPlayer(mediaElement)) {
AddonFilterMediaPluginVideoJS.createPlayer(mediaElement);
if (!youtubeUrl) {
return;
}
return;
}
const iframe = document.createElement('iframe');
iframe.id = video.id;
iframe.src = youtubeUrl;
iframe.setAttribute('frameborder', '0');
iframe.setAttribute('allowfullscreen', '1');
iframe.width = '100%';
iframe.height = '300';
// Replace video tag by the iframe.
video.parentNode?.replaceChild(iframe, video);
// Remove the VideoJS classes and data if present.
mediaElement.classList.remove('video-js');
mediaElement.removeAttribute('data-setup');
mediaElement.removeAttribute('data-setup-lazy');
});
}
}
export const AddonFilterMediaPluginHandler = makeSingleton(AddonFilterMediaPluginHandlerService);
type VideoDataSetup = {
techOrder?: string[];
sources?: {
src?: string;
}[];
};

View File

@ -0,0 +1,188 @@
// (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 { CorePromisedValue } from '@classes/promised-value';
import { CoreExternalContentDirective } from '@directives/external-content';
import { CoreLang } from '@services/lang';
import { CoreTextUtils } from '@services/utils/text';
import { CoreUrlUtils } from '@services/utils/url';
import { makeSingleton } from '@singletons';
import { CoreDirectivesRegistry } from '@singletons/directives-registry';
import { CoreEvents } from '@singletons/events';
import type videojs from 'video.js';
// eslint-disable-next-line no-duplicate-imports
import type { VideoJSOptions, VideoJSPlayer } from 'video.js';
declare module '@singletons/events' {
/**
* Augment CoreEventsData interface with events specific to this service.
*
* @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation
*/
export interface CoreEventsData {
[VIDEO_JS_PLAYER_CREATED]: CoreEventJSVideoPlayerCreated;
}
}
export const VIDEO_JS_PLAYER_CREATED = 'video_js_player_created';
/**
* Wrapper encapsulating videojs functionality.
*/
@Injectable({ providedIn: 'root' })
export class AddonFilterMediaPluginVideoJSService {
protected videojs?: CorePromisedValue<typeof videojs>;
/**
* Create a VideoJS player.
*
* @param element Media element.
*/
async createPlayer(element: HTMLVideoElement | HTMLAudioElement): Promise<void> {
// Wait for external-content to finish in the element and its sources.
await Promise.all([
CoreDirectivesRegistry.waitDirectivesReady(element, undefined, CoreExternalContentDirective),
CoreDirectivesRegistry.waitDirectivesReady(element, 'source', CoreExternalContentDirective),
]);
// Create player.
const videojs = await this.getVideoJS();
const dataSetupString = element.getAttribute('data-setup') || element.getAttribute('data-setup-lazy') || '{}';
const data = CoreTextUtils.parseJSON<VideoJSOptions>(dataSetupString, {});
const player = videojs(
element,
{
controls: true,
techOrder: ['OgvJS'],
language: await CoreLang.getCurrentLanguage(),
controlBar: { pictureInPictureToggle: false },
aspectRatio: data.aspectRatio,
},
() => element.tagName === 'VIDEO' && this.fixVideoJSPlayerSize(player),
);
CoreEvents.trigger(VIDEO_JS_PLAYER_CREATED, {
element,
player,
});
}
/**
* Find a VideoJS player by id.
*
* @param id Element id.
* @returns VideoJS player.
*/
async findPlayer(id: string): Promise<VideoJSPlayer | null> {
const videojs = await this.getVideoJS();
return videojs.getPlayer(id);
}
/**
* Treat Video JS Youtube video links and translate them to iframes.
*
* @param video Video element.
*/
treatYoutubeVideos(video: HTMLElement): void {
if (!video.classList.contains('video-js')) {
return;
}
const dataSetupString = video.getAttribute('data-setup') || video.getAttribute('data-setup-lazy') || '{}';
const data = CoreTextUtils.parseJSON<VideoJSOptions>(dataSetupString, {});
const youtubeUrl = data.techOrder?.[0] == 'youtube' && CoreUrlUtils.getYoutubeEmbedUrl(data.sources?.[0]?.src);
if (!youtubeUrl) {
return;
}
const iframe = document.createElement('iframe');
iframe.id = video.id;
iframe.src = youtubeUrl;
iframe.setAttribute('frameborder', '0');
iframe.setAttribute('allowfullscreen', '1');
iframe.width = '100%';
iframe.height = '300';
// Replace video tag by the iframe.
video.parentNode?.replaceChild(iframe, video);
}
/**
* Gets videojs instance.
*
* @returns VideoJS.
*/
protected async getVideoJS(): Promise<typeof videojs> {
if (!this.videojs) {
this.videojs = new CorePromisedValue();
// Inject CSS.
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'assets/lib/video.js/video-js.min.css';
document.head.appendChild(link);
// Load library.
return import('@addons/filter/mediaplugin/utils/videojs').then(({ initializeVideoJSOgvJS, videojs }) => {
initializeVideoJSOgvJS();
this.videojs?.resolve(videojs);
return videojs;
});
}
return this.videojs;
}
/**
* Fix VideoJS player size.
* If video width is wider than available width, video is cut off. Fix the dimensions in this case.
*
* @param player Player instance.
*/
protected fixVideoJSPlayerSize(player: VideoJSPlayer): void {
const videoWidth = player.videoWidth();
const videoHeight = player.videoHeight();
const playerDimensions = player.currentDimensions();
if (!videoWidth || !videoHeight || !playerDimensions.width || videoWidth === playerDimensions.width) {
return;
}
const candidateHeight = playerDimensions.width * videoHeight / videoWidth;
if (!playerDimensions.height || Math.abs(candidateHeight - playerDimensions.height) > 1) {
player.dimension('height', candidateHeight);
}
}
}
export const AddonFilterMediaPluginVideoJS = makeSingleton(AddonFilterMediaPluginVideoJSService);
/**
* Data passed to VIDEO_JS_PLAYER_CREATED event.
*/
export type CoreEventJSVideoPlayerCreated = {
element: HTMLAudioElement | HTMLVideoElement;
player: VideoJSPlayer;
};

View File

@ -0,0 +1,28 @@
// (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 { VideoJSOgvJS } from '@addons/filter/mediaplugin/classes/videojs-ogvjs';
import { OGVLoader } from 'ogv';
import videojs from 'video.js';
export { videojs };
/**
* Initialize the controller.
*/
export function initializeVideoJSOgvJS(): void {
OGVLoader.base = 'assets/lib/ogv';
videojs.getComponent('Tech').registerTech('OgvJS', VideoJSOgvJS);
}

View File

@ -1,7 +1,7 @@
<ion-header>
<ion-toolbar>
<ion-title>
<h2>{{ 'addon.messages.groupinfo' | translate }}</h2>
<h1>{{ 'addon.messages.groupinfo' | translate }}</h1>
</ion-title>
<ion-buttons slot="end">
<ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
@ -19,18 +19,18 @@
<ion-item class="ion-text-center" *ngIf="conversation">
<ion-label>
<div class="large-avatar">
<img class="avatar" [src]="conversation!.imageurl" core-external-content [alt]="conversation!.name"
<img class="avatar" [src]="conversation.imageurl" core-external-content [alt]="conversation.name"
onError="this.src='assets/img/group-avatar.svg'">
</div>
<h2>
<core-format-text [text]="conversation!.name" contextLevel="system" [contextInstanceId]="0"></core-format-text>
<core-format-text [text]="conversation.name" contextLevel="system" [contextInstanceId]="0"></core-format-text>
</h2>
<p>
<core-format-text *ngIf="conversation!.subname" [text]="conversation!.subname" contextLevel="system"
<core-format-text *ngIf="conversation.subname" [text]="conversation.subname" contextLevel="system"
[contextInstanceId]="0">
</core-format-text>
</p>
<p>{{ 'addon.messages.numparticipants' | translate:{$a: conversation!.membercount} }}</p>
<p>{{ 'addon.messages.numparticipants' | translate:{$a: conversation.membercount} }}</p>
</ion-label>
</ion-item>

View File

@ -7,6 +7,8 @@
<h1>{{ 'addon.messages.contacts' | translate }}</h1>
</ion-title>
<ion-buttons slot="end">
<!-- Add an empty context menu so split view pages can add items, otherwise the menu disappears in some cases. -->
<core-context-menu></core-context-menu>
</ion-buttons>
</ion-toolbar>
</ion-header>

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { IonRefresher } from '@ionic/angular';
import { CoreSites } from '@services/sites';
import {
@ -29,6 +29,7 @@ import { ActivatedRoute } from '@angular/router';
import { Translate } from '@singletons';
import { CoreScreen } from '@services/screen';
import { CoreNavigator } from '@services/navigator';
import { CoreSplitViewComponent } from '@components/split-view/split-view';
/**
* Page that displays the list of contacts.
@ -40,6 +41,8 @@ import { CoreNavigator } from '@services/navigator';
})
export class AddonMessagesContacts35Page implements OnInit, OnDestroy {
@ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent;
protected searchingMessages: string;
protected loadingMessages: string;
protected siteId: string;
@ -244,7 +247,9 @@ export class AddonMessagesContacts35Page implements OnInit, OnDestroy {
const path = CoreNavigator.getRelativePathToParent('/messages/contacts-35') + `discussion/user/${discussionUserId}`;
// @todo Check why this is failing on ngInit.
CoreNavigator.navigate(path);
CoreNavigator.navigate(path, {
reset: CoreScreen.isTablet && !!this.splitView && !this.splitView.isNested,
});
}
/**

View File

@ -10,6 +10,8 @@
<ion-button fill="clear" (click)="gotoSearch()" [attr.aria-label]="'addon.messages.searchcombined' | translate">
<ion-icon name="fas-search" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
<!-- Add an empty context menu so split view pages can add items, otherwise the menu disappears in some cases. -->
<core-context-menu></core-context-menu>
</ion-buttons>
</ion-toolbar>
</ion-header>

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { CoreSites } from '@services/sites';
import {
@ -24,6 +24,7 @@ import { CoreNavigator } from '@services/navigator';
import { CoreScreen } from '@services/screen';
import { CoreDomUtils } from '@services/utils/dom';
import { IonRefresher } from '@ionic/angular';
import { CoreSplitViewComponent } from '@components/split-view/split-view';
/**
* Page that displays contacts and contact requests.
@ -37,6 +38,8 @@ import { IonRefresher } from '@ionic/angular';
})
export class AddonMessagesContactsPage implements OnInit, OnDestroy {
@ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent;
selected: 'confirmed' | 'requests' = 'confirmed';
requestsBadge = '';
selectedUserId?: number; // User id of the conversation opened in the split view.
@ -292,7 +295,9 @@ export class AddonMessagesContactsPage implements OnInit, OnDestroy {
this.selectedUserId = userId;
const path = CoreNavigator.getRelativePathToParent('/messages/contacts') + `discussion/user/${userId}`;
CoreNavigator.navigate(path);
CoreNavigator.navigate(path, {
reset: CoreScreen.isTablet && !!this.splitView && !this.splitView.isNested,
});
}
/**

View File

@ -7,6 +7,8 @@
<h1>{{ 'addon.messages.messages' | translate }}</h1>
</ion-title>
<ion-buttons slot="end">
<!-- Add an empty context menu so split view pages can add items, otherwise the menu disappears in some cases. -->
<core-context-menu></core-context-menu>
<core-user-menu-button></core-user-menu-button>
</ion-buttons>
</ion-toolbar>
@ -29,7 +31,7 @@
[attr.aria-label]="'addon.messages.contacts' | translate" detail="true" button>
<ion-icon name="fas-address-book" slot="start" aria-hidden="true"></ion-icon>
<ion-label>
<h2>{{ 'addon.messages.contacts' | translate }}</h2>
<p class="item-heading">{{ 'addon.messages.contacts' | translate }}</p>
</ion-label>
</ion-item>

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { CoreSites } from '@services/sites';
import {
@ -34,6 +34,7 @@ import { CoreNavigator } from '@services/navigator';
import { CoreScreen } from '@services/screen';
import { CoreMainMenuDeepLinkManager } from '@features/mainmenu/classes/deep-link-manager';
import { CorePlatform } from '@services/platform';
import { CoreSplitViewComponent } from '@components/split-view/split-view';
/**
* Page that displays the list of discussions.
@ -45,6 +46,8 @@ import { CorePlatform } from '@services/platform';
})
export class AddonMessagesDiscussions35Page implements OnInit, OnDestroy {
@ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent;
protected newMessagesObserver: CoreEventObserver;
protected readChangedObserver: CoreEventObserver;
protected appResumeSubscription: Subscription;
@ -264,7 +267,10 @@ export class AddonMessagesDiscussions35Page implements OnInit, OnDestroy {
const path = CoreNavigator.getRelativePathToParent('/messages/index') + `discussion/user/${discussionUserId}`;
await CoreNavigator.navigate(path, { params });
await CoreNavigator.navigate(path, {
params,
reset: CoreScreen.isTablet && !!this.splitView && !this.splitView.isNested,
});
}
/**

View File

@ -13,6 +13,8 @@
<ion-button (click)="gotoSettings()" [attr.aria-label]="'addon.messages.messagepreferences' | translate">
<ion-icon name="fas-cog" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
<!-- Add an empty context menu so split view pages can add items, otherwise the menu disappears in some cases. -->
<core-context-menu></core-context-menu>
<core-user-menu-button></core-user-menu-button>
</ion-buttons>
</ion-toolbar>

View File

@ -38,6 +38,7 @@ import { CoreNavigator } from '@services/navigator';
import { CoreScreen } from '@services/screen';
import { CoreMainMenuDeepLinkManager } from '@features/mainmenu/classes/deep-link-manager';
import { CorePlatform } from '@services/platform';
import { CoreSplitViewComponent } from '@components/split-view/split-view';
/**
* Page that displays the list of conversations, including group conversations.
@ -49,6 +50,8 @@ import { CorePlatform } from '@services/platform';
})
export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy {
@ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent;
@ViewChild(IonContent) content?: IonContent;
@ViewChild('favlist') favListEl?: ElementRef;
@ViewChild('grouplist') groupListEl?: ElementRef;
@ -526,7 +529,10 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy {
const path = CoreNavigator.getRelativePathToParent('/messages/group-conversations') + 'discussion/' +
(conversationId ? conversationId : `user/${userId}`);
await CoreNavigator.navigate(path, { params });
await CoreNavigator.navigate(path, {
params,
reset: CoreScreen.isTablet && !!this.splitView && !this.splitView.isNested,
});
}
/**

View File

@ -7,6 +7,8 @@
<h1>{{ 'addon.messages.searchcombined' | translate }}</h1>
</ion-title>
<ion-buttons slot="end">
<!-- Add an empty context menu so split view pages can add items, otherwise the menu disappears in some cases. -->
<core-context-menu></core-context-menu>
</ion-buttons>
</ion-toolbar>
</ion-header>

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnDestroy } from '@angular/core';
import { Component, OnDestroy, ViewChild } from '@angular/core';
import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { CoreSites } from '@services/sites';
import {
@ -25,6 +25,7 @@ import { CoreDomUtils } from '@services/utils/dom';
import { CoreApp } from '@services/app';
import { CoreNavigator } from '@services/navigator';
import { CoreScreen } from '@services/screen';
import { CoreSplitViewComponent } from '@components/split-view/split-view';
/**
* Page for searching users.
@ -35,6 +36,8 @@ import { CoreScreen } from '@services/screen';
})
export class AddonMessagesSearchPage implements OnDestroy {
@ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent;
disableSearch = false;
displaySearching = false;
displayResults = false;
@ -260,7 +263,9 @@ export class AddonMessagesSearchPage implements OnDestroy {
const path = CoreNavigator.getRelativePathToParent('/messages/search') + 'discussion/' +
(conversationId ? conversationId : `user/${userId}`);
CoreNavigator.navigate(path);
CoreNavigator.navigate(path, {
reset: CoreScreen.isTablet && !!this.splitView && !this.splitView.isNested,
});
}
}

View File

@ -1,7 +1,7 @@
<ion-header>
<ion-toolbar>
<ion-title>
<h2>{{ plugin.name }}</h2>
<h1>{{ plugin.name }}</h1>
</ion-title>
<ion-buttons slot="end">
<ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate">

View File

@ -25,7 +25,7 @@
<ion-item class="ion-text-wrap">
<ion-label>
<h2>{{ 'core.course.hiddenfromstudents' | translate }}</h2>
<p class="item-heading">{{ 'core.course.hiddenfromstudents' | translate }}</p>
<p *ngIf="module.visible">{{ 'core.no' | translate }}</p>
<p *ngIf="!module.visible">{{ 'core.yes' | translate }}</p>
</ion-label>
@ -33,13 +33,13 @@
<ion-item class="ion-text-wrap" *ngIf="timeRemaining">
<ion-label>
<h2>{{ 'addon.mod_assign.timeremaining' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_assign.timeremaining' | translate }}</p>
<p>{{ timeRemaining }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="lateSubmissions">
<ion-label>
<h2>{{ 'addon.mod_assign.latesubmissions' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_assign.latesubmissions' | translate }}</p>
<p>{{ lateSubmissions }}</p>
</ion-label>
</ion-item>
@ -47,8 +47,8 @@
<!-- Summary of all submissions. -->
<ion-item class="ion-text-wrap" *ngIf="summary && summary.participantcount" (click)="goToSubmissionList()" detail="true" button>
<ion-label>
<h2 *ngIf="assign.teamsubmission">{{ 'addon.mod_assign.numberofteams' | translate }}</h2>
<h2 *ngIf="!assign.teamsubmission">{{ 'addon.mod_assign.numberofparticipants' | translate }}</h2>
<p class="item-heading" *ngIf="assign.teamsubmission">{{ 'addon.mod_assign.numberofteams' | translate }}</p>
<p class="item-heading" *ngIf="!assign.teamsubmission">{{ 'addon.mod_assign.numberofparticipants' | translate }}</p>
</ion-label>
<ion-badge slot="end" color="primary">
<span aria-hidden="true">{{ summary.participantcount }}</span>
@ -66,7 +66,7 @@
[class.hide-detail]="!summary.submissiondraftscount" [detail]="true" [button]="summary.submissiondraftscount"
(click)="goToSubmissionList(submissionStatusDraft, !!summary.submissiondraftscount)">
<ion-label>
<h2>{{ 'addon.mod_assign.numberofdraftsubmissions' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_assign.numberofdraftsubmissions' | translate }}</p>
</ion-label>
<ion-badge slot="end" color="primary">
<span aria-hidden="true">{{ summary.submissiondraftscount }}</span>
@ -82,7 +82,7 @@
[class.hide-detail]="!summary.submissionssubmittedcount" [detail]="true" [button]="summary.submissionssubmittedcount"
(click)="goToSubmissionList(submissionStatusSubmitted, !!summary.submissionssubmittedcount)">
<ion-label>
<h2>{{ 'addon.mod_assign.numberofsubmittedassignments' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_assign.numberofsubmittedassignments' | translate }}</p>
</ion-label>
<ion-badge slot="end" color="primary">
<span aria-hidden="true">{{ summary.submissionssubmittedcount }}</span>
@ -98,7 +98,7 @@
[class.hide-detail]="!needsGradingAvailable" [detail]="true" [button]="needsGradingAvailable"
(click)="goToSubmissionList(needGrading, needsGradingAvailable)">
<ion-label>
<h2>{{ 'addon.mod_assign.numberofsubmissionsneedgrading' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_assign.numberofsubmissionsneedgrading' | translate }}</p>
</ion-label>
<ion-badge slot="end" color="primary">
<span aria-hidden="true">{{ summary.submissionsneedgradingcount }}</span>

View File

@ -15,7 +15,7 @@
[attr.aria-label]="user!.fullname">
<core-user-avatar [user]="user" slot="start" [linkProfile]="false"></core-user-avatar>
<ion-label>
<h2>{{ user!.fullname }}</h2>
<p class="item-heading">{{ user!.fullname }}</p>
<ng-container *ngTemplateOutlet="submissionStatus"></ng-container>
</ion-label>
</ion-item>
@ -23,7 +23,7 @@
<!-- Status of the submission if user is blinded. -->
<ion-item class="ion-text-wrap" *ngIf="blindMarking && !user">
<ion-label>
<h2>{{ 'addon.mod_assign.hiddenuser' | translate }} {{blindId}}</h2>
<p class="item-heading">{{ 'addon.mod_assign.hiddenuser' | translate }} {{blindId}}</p>
<ng-container *ngTemplateOutlet="submissionStatus"></ng-container>
</ion-label>
</ion-item>
@ -31,7 +31,7 @@
<!-- Status of the submission in the rest of cases. -->
<ion-item class="ion-text-wrap" *ngIf="(blindMarking && user) || (!blindMarking && !user)">
<ion-label>
<h2>{{ 'addon.mod_assign.submissionstatus' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_assign.submissionstatus' | translate }}</p>
<ng-container *ngTemplateOutlet="submissionStatus"></ng-container>
</ion-label>
</ion-item>
@ -44,7 +44,7 @@
<!-- Render some data about the submission. -->
<ion-item class="ion-text-wrap" *ngIf="currentAttempt && !isGrading">
<ion-label>
<h2>{{ 'addon.mod_assign.attemptnumber' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_assign.attemptnumber' | translate }}</p>
<p *ngIf="assign!.maxattempts == unlimitedAttempts">
{{ 'addon.mod_assign.outof' | translate :
{'$a': {'current': currentAttempt, 'total': maxAttemptsText} } }}
@ -59,7 +59,7 @@
<!-- Submission is locked. -->
<ion-item class="ion-text-wrap" *ngIf="lastAttempt?.locked">
<ion-label>
<h2>{{ 'addon.mod_assign.submissionslocked' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_assign.submissionslocked' | translate }}</p>
</ion-label>
</ion-item>
@ -77,7 +77,7 @@
<ion-item class="ion-text-wrap" *ngIf="showDates && assign!.duedate && !isSubmittedForGrading">
<ion-label>
<h2>{{ 'addon.mod_assign.duedate' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_assign.duedate' | translate }}</p>
<p *ngIf="assign!.duedate">{{ assign!.duedate * 1000 | coreFormatDate }}</p>
<p *ngIf="!assign!.duedate">{{ 'addon.mod_assign.duedateno' | translate }}</p>
</ion-label>
@ -85,14 +85,14 @@
<ion-item class="ion-text-wrap" *ngIf="assign!.duedate && assign!.cutoffdate && isSubmittedForGrading">
<ion-label>
<h2>{{ 'addon.mod_assign.cutoffdate' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_assign.cutoffdate' | translate }}</p>
<p>{{ assign!.cutoffdate * 1000 | coreFormatDate }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="assign!.duedate && lastAttempt?.extensionduedate && !isSubmittedForGrading">
<ion-label>
<h2>{{ 'addon.mod_assign.extensionduedate' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_assign.extensionduedate' | translate }}</p>
<p>{{ lastAttempt!.extensionduedate * 1000 | coreFormatDate }}</p>
</ion-label>
</ion-item>
@ -100,7 +100,7 @@
<!-- Time remaining. -->
<ion-item class="ion-text-wrap" *ngIf="timeRemaining || timeLimitEndTime > 0" [ngClass]="[timeRemainingClass]">
<ion-label>
<h2>{{ 'addon.mod_assign.timeremaining' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_assign.timeremaining' | translate }}</p>
<p *ngIf="!timeLimitEndTime" [innerHTML]="timeRemaining"></p>
<core-timer *ngIf="timeLimitEndTime > 0" [endTime]="timeLimitEndTime" mode="basic" timeUpText="00:00:00"
[timeLeftClassThreshold]="-1" [underTimeClassThresholds]="[300, 900]" (finished)="timeUp()">
@ -111,7 +111,7 @@
<!-- Time limit. -->
<ion-item class="ion-text-wrap" *ngIf="assign && assign.timelimit">
<ion-label>
<h2>{{ 'addon.mod_assign.timelimit' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_assign.timelimit' | translate }}</p>
<p>{{ assign.timelimit | coreDuration }}</p>
</ion-label>
</ion-item>
@ -120,7 +120,7 @@
<ion-item class="ion-text-wrap" *ngIf="lastAttempt && isSubmittedForGrading && lastAttempt!.caneditowner !== undefined"
[ngClass]="{submissioneditable: lastAttempt!.caneditowner, submissionnoteditable: !lastAttempt!.caneditowner}">
<ion-label>
<h2>{{ 'addon.mod_assign.editingstatus' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_assign.editingstatus' | translate }}</p>
<p *ngIf="lastAttempt!.caneditowner">{{ 'addon.mod_assign.submissioneditable' | translate }}</p>
<p *ngIf="!lastAttempt!.caneditowner">{{ 'addon.mod_assign.submissionnoteditable' | translate }}</p>
</ion-label>
@ -130,7 +130,7 @@
<ion-item class="ion-text-wrap"
*ngIf="userSubmission && userSubmission!.status != statusNew && userSubmission!.timemodified">
<ion-label>
<h2>{{ 'addon.mod_assign.timemodified' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_assign.timemodified' | translate }}</p>
<p>{{ userSubmission!.timemodified * 1000 | coreFormatDate }}</p>
</ion-label>
</ion-item>
@ -151,7 +151,7 @@
[attr.aria-label]="user.fullname">
<core-user-avatar [user]="user" slot="start" [linkProfile]="false"></core-user-avatar>
<ion-label>
<h2>{{ user.fullname }}</h2>
<p class="item-heading">{{ user.fullname }}</p>
</ion-label>
</ion-item>
</ng-container>
@ -257,7 +257,7 @@
<ion-item class="ion-text-wrap core-grading-summary"
*ngIf="feedback?.gradefordisplay && (!isGrading || grade.method != 'simple')">
<ion-label>
<h2>{{ 'addon.mod_assign.currentgrade' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_assign.currentgrade' | translate }}</p>
<p>
<core-format-text [text]="feedback!.gradefordisplay" [filter]="false"></core-format-text>
</p>
@ -273,7 +273,7 @@
Use a text input because otherwise we cannot readthe value if it has an invalid character. -->
<ion-item class="ion-text-wrap" *ngIf="grade.method == 'simple' && !grade.scale">
<ion-label position="stacked">
<h2>{{ 'addon.mod_assign.gradeoutof' | translate: {$a: gradeInfo!.grade} }}</h2>
<p class="item-heading">{{ 'addon.mod_assign.gradeoutof' | translate: {$a: gradeInfo!.grade} }}</p>
</ion-label>
<ion-input *ngIf="!grade.disabled" type="text" [(ngModel)]="grade.grade" min="0" [max]="gradeInfo!.grade"
[lang]="grade.lang">
@ -284,7 +284,7 @@
<!-- Grade using a scale. -->
<ion-item class="ion-text-wrap" *ngIf="grade.method == 'simple' && grade.scale">
<ion-label>
<h2>{{ 'addon.mod_assign.grade' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_assign.grade' | translate }}</p>
</ion-label>
<ion-select [(ngModel)]="grade.grade" interface="action-sheet" [disabled]="grade.disabled"
[interfaceOptions]="{header: 'addon.mod_assign.grade' | translate}">
@ -297,7 +297,7 @@
<!-- Outcomes. -->
<ion-item class="ion-text-wrap" *ngFor="let outcome of gradeInfo!.outcomes">
<ion-label>
<h2>{{ outcome.name }}</h2>
<p class="item-heading">{{ outcome.name }}</p>
</ion-label>
<ion-select *ngIf="canSaveGrades && outcome.itemNumber" [(ngModel)]="outcome.selectedId"
interface="action-sheet" [disabled]="gradeInfo!.disabled" [interfaceOptions]="{header: outcome.name }">
@ -311,7 +311,7 @@
<!-- Gradebook grade for simple grading. -->
<ion-item class="ion-text-wrap" *ngIf="grade.method == 'simple'">
<ion-label>
<h2>{{ 'addon.mod_assign.currentgrade' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_assign.currentgrade' | translate }}</p>
<p *ngIf="grade.gradebookGrade && !grade.scale">
{{ grade.gradebookGrade }}
</p>
@ -332,7 +332,7 @@
<!-- Workflow status. -->
<ion-item class="ion-text-wrap" *ngIf="workflowStatusTranslationId">
<ion-label>
<h2>{{ 'addon.mod_assign.markingworkflowstate' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_assign.markingworkflowstate' | translate }}</p>
<p>{{ workflowStatusTranslationId | translate }}</p>
</ion-label>
</ion-item>
@ -340,7 +340,7 @@
<!--- Apply grade to all team members. -->
<ion-item class="ion-text-wrap" *ngIf="assign!.teamsubmission && canSaveGrades">
<ion-label>
<h2>{{ 'addon.mod_assign.groupsubmissionsettings' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_assign.groupsubmissionsettings' | translate }}</p>
<p>{{ 'addon.mod_assign.applytoteam' | translate }}</p>
</ion-label>
<ion-toggle [(ngModel)]="grade.applyToAll"></ion-toggle>
@ -350,7 +350,7 @@
<ng-container *ngIf="isGrading && assign!.attemptreopenmethod != attemptReopenMethodNone">
<ion-item class="ion-text-wrap">
<ion-label>
<h2>{{ 'addon.mod_assign.attemptsettings' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_assign.attemptsettings' | translate }}</p>
<p *ngIf="assign!.maxattempts == unlimitedAttempts">
{{ 'addon.mod_assign.outof' | translate :
{'$a': {'current': currentAttempt, 'total': maxAttemptsText} } }}
@ -376,8 +376,8 @@
[attr.aria-label]="grader!.fullname" detail="true">
<core-user-avatar [user]="grader" slot="start" [linkProfile]="false"></core-user-avatar>
<ion-label>
<h2>{{ 'addon.mod_assign.gradedby' | translate }}</h2>
<h2>{{ grader!.fullname }}</h2>
<p class="item-heading">{{ 'addon.mod_assign.gradedby' | translate }}</p>
<p class="item-heading">{{ grader!.fullname }}</p>
<p *ngIf="feedback!.gradeddate">{{ feedback!.gradeddate * 1000 | coreFormatDate }}</p>
</ion-label>
</ion-item>
@ -385,7 +385,7 @@
<!-- Grader is hidden, display only the grade date. -->
<ion-item class="ion-text-wrap" *ngIf="!grader && feedback?.gradeddate">
<ion-label>
<h2>{{ 'addon.mod_assign.gradedon' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_assign.gradedon' | translate }}</p>
<p>{{ feedback!.gradeddate * 1000 | coreFormatDate }}</p>
</ion-label>
</ion-item>

View File

@ -20,7 +20,7 @@
</ion-item-divider>
<ion-item class="ion-text-wrap" *ngIf="wordLimitEnabled && words >= 0">
<ion-label>
<h2>{{ 'addon.mod_assign.wordlimit' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_assign.wordlimit' | translate }}</p>
<p>{{ 'core.numwords' | translate: {'$a': words + ' / ' + wordLimit} }}</p>
</ion-label>
</ion-item>

View File

@ -19,13 +19,13 @@
<ng-container *ngIf="meetingInfo && showRoom">
<ion-item class="ion-text-wrap" *ngIf="meetingInfo.openingtime">
<ion-label>
<h3>{{ 'addon.mod_bigbluebuttonbn.mod_form_field_openingtime' | translate }}</h3>
<p class="item-heading">{{ 'addon.mod_bigbluebuttonbn.mod_form_field_openingtime' | translate }}</p>
</ion-label>
<p slot="end">{{ meetingInfo.openingtime * 1000 | coreFormatDate }}</p>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="meetingInfo.closingtime">
<ion-label>
<h3>{{ 'addon.mod_bigbluebuttonbn.mod_form_field_closingtime' | translate }}</h3>
<p class="item-heading">{{ 'addon.mod_bigbluebuttonbn.mod_form_field_closingtime' | translate }}</p>
</ion-label>
<p slot="end">{{ meetingInfo.closingtime * 1000 | coreFormatDate }}</p>
</ion-item>
@ -45,31 +45,31 @@
<ion-item class="ion-text-wrap" *ngIf="meetingInfo.startedat">
<ion-label>
<h3>{{ 'addon.mod_bigbluebuttonbn.view_message_session_started_at' | translate }}</h3>
<p class="item-heading">{{ 'addon.mod_bigbluebuttonbn.view_message_session_started_at' | translate }}</p>
</ion-label>
<p slot="end">{{ meetingInfo.startedat * 1000 | coreFormatDate: "strftimetime" }}</p>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
<h3 *ngIf="meetingInfo.moderatorplural">
<p class="item-heading" *ngIf="meetingInfo.moderatorplural">
{{ 'addon.mod_bigbluebuttonbn.view_message_moderators' | translate }}
</h3>
<h3 *ngIf="!meetingInfo.moderatorplural">
</p>
<p class="item-heading" *ngIf="!meetingInfo.moderatorplural">
{{ 'addon.mod_bigbluebuttonbn.view_message_moderator' | translate }}
</h3>
</p>
</ion-label>
<p slot="end">{{ meetingInfo.moderatorcount }}</p>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
<h3 *ngIf="meetingInfo.participantplural">
<p class="item-heading" *ngIf="meetingInfo.participantplural">
{{ 'addon.mod_bigbluebuttonbn.view_message_viewers' | translate }}
</h3>
<h3 *ngIf="!meetingInfo.participantplural">
</p>
<p class="item-heading" *ngIf="!meetingInfo.participantplural">
{{ 'addon.mod_bigbluebuttonbn.view_message_viewer' | translate }}
</h3>
</p>
</ion-label>
<p slot="end">{{ meetingInfo.participantcount }}</p>
</ion-item>
@ -108,7 +108,7 @@
<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 class="item-heading">{{ data.label }}</p>
<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>

View File

@ -24,8 +24,8 @@
(click)="openBook(chapter.id)">
<ion-label>
<p [class.ion-padding-start]="addPadding && chapter.level == 1 ? true : null">
<span *ngIf="showNumbers" class="addon-mod-book-number">{{chapter.indexNumber}}&nbsp;</span>
<span *ngIf="showBullets" class="addon-mod-book-bullet">&bull;&nbsp;</span>
<span *ngIf="showNumbers" class="addon-mod-book-number">{{chapter.indexNumber}} </span>
<span *ngIf="showBullets" class="addon-mod-book-bullet">&bull; </span>
<core-format-text [text]="chapter.title" contextLevel="module" [contextInstanceId]="module.id" [courseId]="courseId">
</core-format-text>
</p>

View File

@ -18,6 +18,7 @@ import { AddonModBook, AddonModBookBookWSData, AddonModBookNumbering, AddonModBo
import { CoreCourseContentsPage } from '@features/course/pages/contents/contents';
import { CoreCourse } from '@features/course/services/course';
import { CoreNavigator } from '@services/navigator';
import { AddonModBookModuleHandlerService } from '../../services/handlers/module';
/**
* Component that displays a book entry page.
@ -60,6 +61,13 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp
]);
}
/**
* @inheritdoc
*/
protected async invalidateContent(): Promise<void> {
await AddonModBook.invalidateContent(this.module.id, this.courseId);
}
/**
* Load book data.
*
@ -102,14 +110,11 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp
*
* @param chapterId Chapter to open, undefined for last chapter viewed.
*/
openBook(chapterId?: number): void {
CoreNavigator.navigate('contents', {
params: {
cmId: this.module.id,
courseId: this.courseId,
chapterId,
},
});
async openBook(chapterId?: number): Promise<void> {
await CoreNavigator.navigateToSitePath(
`${AddonModBookModuleHandlerService.PAGE_NAME}/${this.courseId}/${this.module.id}/contents`,
{ params: { chapterId } },
);
this.hasStartedBook = true;
}

View File

@ -1,7 +1,7 @@
<ion-header>
<ion-toolbar>
<ion-title>
<h2>{{ 'addon.mod_book.toc' | translate }}</h2>
<h1>{{ 'addon.mod_book.toc' | translate }}</h1>
</ion-title>
<ion-buttons slot="end">
<ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
@ -17,8 +17,8 @@
[attr.aria-current]="selected == chapter.id ? 'page' : 'false'" button [class.item-dimmed]="chapter.hidden" detail="false">
<ion-label>
<p [class.ion-padding-start]="addPadding && chapter.level == 1 ? true : null" class="item-heading">
<span *ngIf="showNumbers" class="addon-mod-book-number">{{chapter.indexNumber}}&nbsp;</span>
<span *ngIf="showBullets" class="addon-mod-book-bullet">&bull;&nbsp;</span>
<span *ngIf="showNumbers" class="addon-mod-book-number">{{chapter.indexNumber}} </span>
<span *ngIf="showBullets" class="addon-mod-book-bullet">&bull; </span>
<core-format-text [text]="chapter.title" contextLevel="module" [contextInstanceId]="moduleId" [courseId]="courseId">
</core-format-text>
</p>

View File

@ -291,7 +291,9 @@ export class AddonModBookProvider {
});
}
chapterNumber++;
if (!parseInt(chapter.hidden, 10)) {
chapterNumber++;
}
});
return chapters;

View File

@ -0,0 +1,308 @@
@mod @mod_book @app @javascript
Feature: Test basic usage of book activity in app
In order to view a book while using the mobile app
As a student
I need basic book 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 | numbering |
| book | Basic book | Test book description | C1 | book | 1 |
And the following "mod_book > chapter" exist:
| book | title | content | subchapter | hidden | pagenum |
| Basic book | Chapt 1 | This is the first chapter | 0 | 0 | 1 |
| Basic book | Chapt 1.1 | This is a subchapter | 1 | 0 | 2 |
| Basic book | Chapt 2 | This is the second chapter | 0 | 0 | 3 |
| Basic book | Hidden chapter | This is a hidden chapter | 0 | 1 | 4 |
| Basic book | Hidden subchapter | This is a hidden subchapter | 1 | 1 | 5 |
| Basic book | Chapt 3 | This is the third chapter | 0 | 0 | 6 |
| Basic book | Last hidden | Another hidden subchapter | 1 | 1 | 7 |
Scenario: View book table of contents (student)
Given I entered the course "Course 1" as "student1" in the app
And I press "Basic book" in the app
Then I should find "Test book description" in the app
And I should find "Chapt 1" in the app
And I should find "Chapt 1.1" in the app
And I should find "Chapt 2" in the app
And I should find "Chapt 3" in the app
And I should find "Start" in the app
But I should not find "Hidden chapter" in the app
And I should not find "Hidden subchapter" in the app
And I should not find "Last hidden" in the app
And I should not find "This is the first chapter" in the app
When I press "Start" in the app
And I press "Table of contents" in the app
Then I should find "Chapt 1" in the app
And I should find "Chapt 1.1" in the app
And I should find "Chapt 2" in the app
And I should find "Chapt 3" in the app
But I should not find "Hidden chapter" in the app
And I should not find "Hidden subchapter" in the app
And I should not find "Last hidden" in the app
Scenario: View book table of contents (teacher)
Given I entered the course "Course 1" as "teacher1" in the app
And I press "Basic book" in the app
Then I should find "Test book description" in the app
And I should find "Chapt 1" in the app
And I should find "Chapt 1.1" in the app
And I should find "Chapt 2" in the app
And I should find "Hidden chapter" in the app
And I should find "Hidden subchapter" in the app
And I should find "Chapt 3" in the app
And I should find "Last hidden" in the app
And I should find "Start" in the app
And I should not find "This is the first chapter" in the app
When I press "Start" in the app
And I press "Table of contents" in the app
Then I should find "Chapt 1" in the app
And I should find "Chapt 1.1" in the app
And I should find "Chapt 2" in the app
And I should find "Hidden chapter" in the app
And I should find "Hidden subchapter" in the app
And I should find "Chapt 3" in the app
And I should find "Last hidden" in the app
Scenario: Open chapters from table of contents
Given I entered the course "Course 1" as "student1" in the app
And I press "Basic book" in the app
When I press "Chapt 1" in the app
Then I should find "Chapt 1" in the app
And I should find "This is the first chapter" in the app
But I should not find "This is the second chapter" in the app
When I press the back button in the app
And I press "Chapt 2" in the app
Then I should find "Chapt 2" in the app
And I should find "This is the second chapter" in the app
But I should not find "This is the first chapter" in the app
Scenario: View and navigate book contents (student)
Given I entered the course "Course 1" as "student1" in the app
And I press "Basic book" in the app
And I press "Start" in the app
Then I should find "Chapt 1" in the app
And I should find "This is the first chapter" in the app
And I should find "1 / 4" in the app
When I press "Next" in the app
Then I should find "Chapt 1.1" in the app
And I should find "This is a subchapter" in the app
And I should find "2 / 4" in the app
But I should not find "This is the first chapter" in the app
When I press "Next" in the app
Then I should find "Chapt 2" in the app
And I should find "This is the second chapter" in the app
And I should find "3 / 4" in the app
But I should not find "This is a subchapter" in the app
When I press "Previous" in the app
Then I should find "Chapt 1.1" in the app
And I should find "This is a subchapter" in the app
And I should find "2 / 4" in the app
But I should not find "This is the second chapter" in the app
# Navigate using TOC.
When I press "Table of contents" in the app
And I press "Chapt 1" in the app
Then I should find "Chapt 1" in the app
And I should find "This is the first chapter" in the app
And I should find "1 / 4" in the app
But I should not find "This is a subchapter" in the app
When I press "Table of contents" in the app
And I press "Chapt 3" in the app
Then I should find "Chapt 3" in the app
And I should find "This is the third chapter" in the app
And I should find "4 / 4" in the app
But I should not find "This is the first chapter" in the app
# Navigate using swipe.
When I swipe to the left in "Chapt 3" "ion-slides" in the app
Then I should find "Chapt 3" in the app
And I should find "This is the third chapter" in the app
And I should find "4 / 4" in the app
When I swipe to the right in "Chapt 3" "ion-slides" in the app
Then I should find "Chapt 2" in the app
And I should find "This is the second chapter" in the app
And I should find "3 / 4" in the app
When I swipe to the right in "Chapt 2" "ion-slides" in the app
Then I should find "Chapt 1.1" in the app
And I should find "This is a subchapter" in the app
And I should find "2 / 4" in the app
When I swipe to the left in "Chapt 1.1" "ion-slides" in the app
Then I should find "Chapt 2" in the app
And I should find "This is the second chapter" in the app
And I should find "3 / 4" in the app
Scenario: View and navigate book contents (teacher)
Given I entered the course "Course 1" as "teacher1" in the app
And I press "Basic book" in the app
And I press "Start" in the app
Then I should find "Chapt 1" in the app
And I should find "This is the first chapter" in the app
And I should find "1 / 7" in the app
When I press "Next" in the app
Then I should find "Chapt 1.1" in the app
And I should find "This is a subchapter" in the app
And I should find "2 / 7" in the app
But I should not find "This is the first chapter" in the app
When I press "Next" in the app
Then I should find "Chapt 2" in the app
And I should find "This is the second chapter" in the app
And I should find "3 / 7" in the app
But I should not find "This is a subchapter" in the app
When I press "Next" in the app
Then I should find "Hidden chapter" in the app
And I should find "This is a hidden chapter" in the app
And I should find "4 / 7" in the app
But I should not find "This is the second chapter" in the app
When I press "Next" in the app
Then I should find "Hidden subchapter" in the app
And I should find "This is a hidden subchapter" in the app
And I should find "5 / 7" in the app
But I should not find "This is a hidden chapter" in the app
When I press "Previous" in the app
Then I should find "Hidden chapter" in the app
And I should find "This is a hidden chapter" in the app
And I should find "4 / 7" in the app
But I should not find "This is a hidden subchapter" in the app
# Navigate using TOC.
When I press "Table of contents" in the app
And I press "Chapt 1" in the app
Then I should find "Chapt 1" in the app
And I should find "This is the first chapter" in the app
And I should find "1 / 7" in the app
But I should not find "This is a hidden chapter" in the app
When I press "Table of contents" in the app
And I press "Hidden subchapter" in the app
Then I should find "Hidden subchapter" in the app
And I should find "This is a hidden subchapter" in the app
And I should find "5 / 7" in the app
But I should not find "This is the first chapter" in the app
# Navigate using swipe.
When I swipe to the left in "Hidden subchapter" "ion-slides" in the app
Then I should find "Chapt 3" in the app
And I should find "This is the third chapter" in the app
And I should find "6 / 7" in the app
When I swipe to the left in "Chapt 3" "ion-slides" in the app
Then I should find "Last hidden" in the app
And I should find "Another hidden subchapter" in the app
And I should find "7 / 7" in the app
When I swipe to the left in "Last hidden" "ion-slides" in the app
Then I should find "Last hidden" in the app
And I should find "Another hidden subchapter" in the app
And I should find "7 / 7" in the app
When I swipe to the right in "Last hidden" "ion-slides" in the app
Then I should find "Chapt 3" in the app
And I should find "This is the third chapter" in the app
And I should find "6 / 7" in the app
Scenario: Link to book opens chapter content
Given I entered the book activity "Basic book" on course "Course 1" as "student1" in the app
Then I should find "This is the first chapter" in the app
Scenario: Test numbering (student)
Given the following "activities" exist:
| activity | name | intro | course | idnumber | numbering |
| book | Bull book | Test book description | C1 | book2 | 2 |
| book | Ind book | Test book description | C1 | book2 | 3 |
| book | None book | Test book description | C1 | book2 | 0 |
And the following "mod_book > chapter" exist:
| book | title | content | subchapter | hidden | pagenum |
| Bull book | Chapt 1 | This is the first chapter | 0 | 0 | 1 |
| Ind book | Chapt 1 | This is the first chapter | 0 | 0 | 1 |
| None book | Chapt 1 | This is the first chapter | 0 | 0 | 1 |
And I entered the course "Course 1" as "student1" in the app
And I press "Basic book" in the app
Then I should find "1. Chapt 1" in the app
And I should find "1.1. Chapt 1.1" in the app
And I should find "2. Chapt 2" in the app
And I should find "3. Chapt 3" in the app
When I press "Start" in the app
And I press "Table of contents" in the app
Then I should find "1. Chapt 1" in the app
And I should find "1.1. Chapt 1.1" in the app
And I should find "2. Chapt 2" in the app
And I should find "3. Chapt 3" in the app
When I press "Close" in the app
And I press the back button in the app
And I press the back button in the app
And I press "Bull book" in the app
Then I should find " Chapt 1" in the app
But I should not find "1. Chapt 1" in the app
When I press "Start" in the app
And I press "Table of contents" in the app
Then I should find " Chapt 1" in the app
But I should not find "1. Chapt 1" in the app
When I press "Close" in the app
And I press the back button in the app
And I press the back button in the app
And I press "Ind book" in the app
Then I should find "Chapt 1" in the app
But I should not find " Chapt 1" in the app
And I should not find "1. Chapt 1" in the app
When I press "Start" in the app
And I press "Table of contents" in the app
Then I should find "Chapt 1" in the app
But I should not find " Chapt 1" in the app
And I should not find "1. Chapt 1" in the app
When I press "Close" in the app
And I press the back button in the app
And I press the back button in the app
And I press "None book" in the app
Then I should find "Chapt 1" in the app
But I should not find " Chapt 1" in the app
And I should not find "1. Chapt 1" in the app
When I press "Start" in the app
And I press "Table of contents" in the app
Then I should find "Chapt 1" in the app
But I should not find " Chapt 1" in the app
And I should not find "1. Chapt 1" in the app
Scenario: Test numbering (teacher)
Given I entered the course "Course 1" as "teacher1" in the app
And I press "Basic book" in the app
Then I should find "1. Chapt 1" in the app
And I should find "1.1. Chapt 1.1" in the app
And I should find "2. Chapt 2" in the app
And I should find "x. Hidden chapter" in the app
And I should find "x.x. Hidden subchapter" in the app
And I should find "3. Chapt 3" in the app
And I should find "3.x. Last hidden" in the app

View File

@ -0,0 +1,33 @@
@app @javascript @mod @mod_book
Feature: Test single activity of book type in app
In order to view a book while using the mobile app
As a student
I need single activity of book type functionality to work
Background:
Given the following "users" exist:
| username | firstname | lastname |
| student1 | First | Student |
And the following "courses" exist:
| fullname | shortname | category | format | activitytype |
| Course 1 | C1 | 0 | singleactivity | book |
And the following "course enrolments" exist:
| user | course | role |
| student1 | C1 | student |
And the following "activity" exist:
| activity | name | intro | course | idnumber | numbering | section |
| book | Single activity book | Test book description | C1 | 1 | 1 | 0 |
And the following "mod_book > chapter" exist:
| book | title | content | subchapter | hidden | pagenum |
| Single activity book | Chapt 1 | This is the first chapter | 0 | 0 | 1 |
| Single activity book | Chapt 2 | This is the second chapter | 0 | 0 | 1 |
| Single activity book | Chapt 3 | This is the third chapter | 0 | 0 | 1 |
Scenario: Single activity book
Given I entered the course "Course 1" as "student1" in the app
Then I should find "Chapt 1" in the app
And I should find "Chapt 2" in the app
And I press "Chapt 1" in the app
Then I should find "Chapt 1" in the app
And I should find "This is the first chapter" in the app
But I should not find "This is the second chapter" in the app

View File

@ -1,7 +1,7 @@
<ion-header>
<ion-toolbar>
<ion-title>
<h2>{{ 'addon.mod_chat.currentusers' | translate }}</h2>
<h1>{{ 'addon.mod_chat.currentusers' | translate }}</h1>
</ion-title>
<ion-buttons slot="end">
<ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate">

View File

@ -1,7 +1,7 @@
<ion-header>
<ion-toolbar>
<ion-title>
<h2>{{ 'addon.mod_data.search' | translate }}</h2>
<h1>{{ 'addon.mod_data.search' | translate }}</h1>
</ion-title>
<ion-buttons slot="end">
<ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate">

View File

@ -11,6 +11,7 @@
// 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 {
AddonModDataEntryField,
AddonModDataField,

View File

@ -11,6 +11,7 @@
// 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 { Component } from '@angular/core';
import { AddonModDataEntryField, AddonModDataProvider } from '@addons/mod/data/services/data';
import { AddonModDataFieldPluginBaseComponent } from '@addons/mod/data/classes/base-field-plugin-component';

View File

@ -18,8 +18,8 @@ import { Component } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { SafeUrl } from '@angular/platform-browser';
import { CoreAnyError } from '@classes/errors/error';
import { CoreApp } from '@services/app';
import { CoreGeolocation, CoreGeolocationError, CoreGeolocationErrorReason } from '@services/geolocation';
import { CorePlatform } from '@services/platform';
import { CoreDomUtils } from '@services/utils/dom';
import { DomSanitizer } from '@singletons';
@ -73,7 +73,7 @@ export class AddonModDataFieldLatlongComponent extends AddonModDataFieldPluginBa
const northFixed = north ? north.toFixed(4) : '0.0000';
const eastFixed = east ? east.toFixed(4) : '0.0000';
if (CoreApp.isIOS()) {
if (CorePlatform.isIOS()) {
url = 'http://maps.apple.com/?ll=' + northFixed + ',' + eastFixed + '&near=' + northFixed + ',' + eastFixed;
} else {
url = 'geo:' + northFixed + ',' + eastFixed;

View File

@ -45,17 +45,17 @@ export class AddonModDataFieldNumberHandlerService extends AddonModDataFieldText
originalFieldData: AddonModDataEntryField,
): boolean {
const fieldName = 'f_' + field.id;
const input = inputData[fieldName] || '';
const content = originalFieldData?.content || '';
const input = inputData[fieldName] ?? '';
const content = originalFieldData?.content ?? '';
return input != content;
return input !== content;
}
/**
* @inheritdoc
*/
getFieldsNotifications(field: AddonModDataField, inputData: AddonModDataSubfieldData[]): string | undefined {
if (field.required && (!inputData || !inputData.length || inputData[0].value == '')) {
if (field.required && (!inputData || !inputData.length || inputData[0].value === '')) {
return Translate.instant('addon.mod_data.errormustsupplyvalue');
}
}

View File

@ -11,6 +11,7 @@
// 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 { AddonModDataEntryField, AddonModDataProvider } from '@addons/mod/data/services/data';
import { Component } from '@angular/core';
import { CoreFileEntry, CoreFileHelper } from '@services/file-helper';

View File

@ -11,6 +11,7 @@
// 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 { Component } from '@angular/core';
import { AddonModDataFieldPluginBaseComponent } from '../../../classes/base-field-plugin-component';

View File

@ -70,7 +70,7 @@ export class AddonModDataFieldTextHandlerService implements AddonModDataFieldHan
return [{
fieldid: field.id,
value: inputData[fieldName] || '',
value: inputData[fieldName] ?? '',
}];
}
@ -83,10 +83,10 @@ export class AddonModDataFieldTextHandlerService implements AddonModDataFieldHan
originalFieldData: AddonModDataEntryField,
): boolean {
const fieldName = 'f_' + field.id;
const input = inputData[fieldName] || '';
const content = originalFieldData?.content || '';
const input = inputData[fieldName] ?? '';
const content = originalFieldData?.content ?? '';
return input != content;
return input !== content;
}
/**
@ -102,7 +102,7 @@ export class AddonModDataFieldTextHandlerService implements AddonModDataFieldHan
* @inheritdoc
*/
overrideData(originalContent: AddonModDataEntryField, offlineContent: CoreFormFields<string>): AddonModDataEntryField {
originalContent.content = offlineContent[''] || '';
originalContent.content = offlineContent[''] ?? '';
return originalContent;
}

View File

@ -11,6 +11,7 @@
// 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 { AddonModDataEntryField } from '@addons/mod/data/services/data';
import { Component } from '@angular/core';
import { AddonModDataFieldPluginBaseComponent } from '../../../classes/base-field-plugin-component';

View File

@ -42,6 +42,7 @@ import {
import { AddonModDataHelper } from '../../services/data-helper';
import { CoreDom } from '@singletons/dom';
import { AddonModDataEntryFieldInitialized } from '../../classes/base-field-plugin-component';
import { CoreTextUtils } from '@services/utils/text';
/**
* Page that displays the view edit page.
@ -368,9 +369,18 @@ export class AddonModDataEditPage implements OnInit {
}
});
}
this.jsData!.errors = this.errors;
this.scrollToFirstError();
if (updateEntryResult.generalnotifications?.length) {
CoreDomUtils.showAlertWithOptions({
header: Translate.instant('core.notice'),
message: CoreTextUtils.buildMessage(updateEntryResult.generalnotifications),
buttons: [Translate.instant('core.ok')],
});
}
}
} finally {
modal.dismiss();

View File

@ -590,8 +590,8 @@ export class AddonModDataHelperProvider {
// WS wants values in JSON format.
entryFieldDataToSend.push({
fieldid: fieldSubdata.fieldid,
subfield: fieldSubdata.subfield || '',
value: value ? JSON.stringify(value) : '',
subfield: fieldSubdata.subfield ?? '',
value: (value || value === 0) ? JSON.stringify(value) : '',
});
return;

View File

@ -206,3 +206,22 @@ Feature: Users can manage entries in database activities
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
Scenario: Handle number 0 correctly when creating entries
Given the following "activities" exist:
| activity | name | intro | course | idnumber |
| data | Number DB | Number DB | C1 | data2 |
And the following "mod_data > fields" exist:
| database | type | name | description |
| data2 | number | Number | Number value |
And I entered the data activity "Number DB" on course "Course 1" as "student1" in the app
When I press "Add entries" in the app
And I press "Save" near "Number DB" in the app
Then I should find "You did not fill out any fields!" in the app
When I press "OK" in the app
And I set the following fields to these values in the app:
| Number | 0 |
And I press "Save" near "Number DB" in the app
Then I should find "0" near "Number:" in the app
But I should not find "Save" in the app

View File

@ -37,7 +37,8 @@
<div collapsible-footer *ngIf="!showLoading" slot="fixed">
<div class="list-item-limited-width adaptable-buttons-row"
*ngIf="access && (access.canedititems || access.canviewreports || !access.isempty)">
<ion-button expand="block" fill="outline" (click)="gotoAnswerQuestions(true)" class="ion-margin ion-text-wrap">
<ion-button *ngIf="access.canedititems || access.canviewreports" expand="block" fill="outline"
(click)="gotoAnswerQuestions(true)" class="ion-margin ion-text-wrap">
<ion-icon name="fas-search" slot="start" aria-hidden="true"></ion-icon>
{{ 'addon.mod_feedback.preview' | translate }}
</ion-button>
@ -64,7 +65,7 @@
<ion-item class="ion-text-wrap" (click)="openAttempts()" [class.hide-detail]="!(access.canviewreports && completedCount > 0)"
[detail]="true" [button]="access.canviewreports && completedCount > 0">
<ion-label>
<h2>{{ 'addon.mod_feedback.completed_feedbacks' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_feedback.completed_feedbacks' | translate }}</p>
</ion-label>
<ion-badge slot="end">
<span aria-hidden="true">{{completedCount}}</span>
@ -76,12 +77,12 @@
<ion-item class="ion-text-wrap" *ngIf="!access.isanonymous && access.canviewreports" (click)="openNonRespondents()" detail="true"
button>
<ion-label>
<h2>{{ 'addon.mod_feedback.show_nonrespondents' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_feedback.show_nonrespondents' | translate }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
<h2>{{ 'addon.mod_feedback.questions' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_feedback.questions' | translate }}</p>
</ion-label>
<ion-badge slot="end">
<span aria-hidden="true">{{itemsCount}}</span>
@ -115,19 +116,19 @@
<ion-list *ngIf="access && (access.canedititems || access.canviewreports || !access.isempty)">
<ion-item class="ion-text-wrap" *ngIf="access.canedititems && overview.timeopen">
<ion-label>
<h2>{{ 'addon.mod_feedback.feedbackopen' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_feedback.feedbackopen' | translate }}</p>
<p>{{overview.openTimeReadable}}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="access.canedititems && overview.timeclose">
<ion-label>
<h2>{{ 'addon.mod_feedback.feedbackclose' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_feedback.feedbackclose' | translate }}</p>
<p>{{overview.closeTimeReadable}}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="access.canedititems && feedback && feedback.page_after_submit">
<ion-label>
<h2>{{ 'addon.mod_feedback.page_after_submit' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_feedback.page_after_submit' | translate }}</p>
<core-format-text [component]="component" [componentId]="componentId" [text]="feedback.page_after_submit"
contextLevel="module" [contextInstanceId]="module.id" [courseId]="courseId">
</core-format-text>
@ -136,7 +137,7 @@
<ng-container *ngIf="!access.isempty">
<ion-item class="ion-text-wrap">
<ion-label>
<h2>{{ 'addon.mod_feedback.mode' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_feedback.mode' | translate }}</p>
<p *ngIf="access.isanonymous">{{ 'addon.mod_feedback.anonymous' | translate }}</p>
<p *ngIf="!access.isanonymous">{{ 'addon.mod_feedback.non_anonymous' | translate }}</p>
</ion-label>

View File

@ -33,12 +33,12 @@
<core-spacer *ngIf="item.typ == 'pagebreak'"></core-spacer>
<ion-item class="ion-text-wrap" *ngIf="item.typ != 'pagebreak'" [color]="item.dependitem > 0 ? 'light' : ''">
<ion-label>
<h2 *ngIf="item.name" [core-mark-required]="item.required">
<p class="item-heading" *ngIf="item.name" [core-mark-required]="item.required">
<span *ngIf="feedback!.autonumbering && item.itemnumber">{{item.itemnumber}}. </span>
<core-format-text [component]="component" [componentId]="cmId" [text]="item.name" contextLevel="module"
[contextInstanceId]="cmId" [courseId]="courseId">
</core-format-text>
</h2>
</p>
<p *ngIf="item.submittedValue">
<core-format-text [component]="component" [componentId]="cmId" [text]="item.submittedValue"
contextLevel="module" [contextInstanceId]="cmId" [courseId]="courseId">

View File

@ -17,7 +17,7 @@
<ion-list class="ion-no-margin has-spacer">
<ion-item class="ion-text-wrap">
<ion-label>
<h2>{{ 'addon.mod_feedback.mode' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_feedback.mode' | translate }}</p>
<p *ngIf="access!.isanonymous">{{ 'addon.mod_feedback.anonymous' | translate }}</p>
<p *ngIf="!access!.isanonymous">{{ 'addon.mod_feedback.non_anonymous' | translate }}</p>
</ion-label>

View File

@ -109,6 +109,14 @@ export class AddonModFeedbackFormPage implements OnInit, OnDestroy, CanLeave {
await this.fetchData();
if (!this.access || this.access.isempty && (!this.access.canedititems && !this.access.canviewreports)) {
CoreDomUtils.showErrorModal(Translate.instant('core.nopermissiontoaccesspage'));
CoreNavigator.back();
return;
}
if (!this.feedback) {
return;
}

View File

@ -3,6 +3,8 @@
<ion-button fill="clear" (click)="openModuleSummary()" aria-haspopup="true" [attr.aria-label]="'core.info' | translate">
<ion-icon name="fas-info-circle" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
<!-- Add an empty context menu so split view pages can add items, otherwise the menu disappears in some cases. -->
<core-context-menu></core-context-menu>
</core-navbar-buttons>
<!-- Content. -->
@ -74,26 +76,20 @@
[lines]="discussion.groupname && 'none'" [attr.aria-current]="discussions?.getItemAriaCurrent(discussion)"
(click)="discussions?.select(discussion)" button>
<ion-label>
<div class="addon-mod-forum-discussion-title">
<p class="ion-text-wrap item-heading">
<ion-icon name="fas-map-pin" *ngIf="discussion.pinned"
[attr.aria-label]="'addon.mod_forum.discussionpinned' | translate"></ion-icon>
<ion-icon name="fas-star" class="addon-forum-star" *ngIf="!discussion.pinned && discussion.starred"
[attr.aria-label]="'addon.mod_forum.favourites' | translate"></ion-icon>
<core-format-text [text]="discussion.subject" contextLevel="module" [contextInstanceId]="module && module.id"
[courseId]="courseId">
</core-format-text>
<ion-icon name="fas-lock" *ngIf="discussion.locked" class="addon-mod-forum-locked-icon"
[attr.aria-label]="'addon.mod_forum.discussionlocked' | translate"></ion-icon>
</p>
<ion-button *ngIf="canPin || discussion.canlock || discussion.canfavourite" fill="clear"
[attr.aria-label]="('core.displayoptions' | translate)" (click)="showOptionsMenu($event, discussion)">
<ion-icon name="ellipsis-vertical" slot="icon-only" aria-hidden="true">
</ion-icon>
</ion-button>
</div>
<p class="addon-mod-forum-discussion-title ion-text-wrap item-heading">
<ion-icon name="fas-map-pin" *ngIf="discussion.pinned"
[attr.aria-label]="'addon.mod_forum.discussionpinned' | translate"></ion-icon>
<ion-icon name="fas-star" class="addon-forum-star" *ngIf="!discussion.pinned && discussion.starred"
[attr.aria-label]="'addon.mod_forum.favourites' | translate"></ion-icon>
<core-format-text [text]="discussion.subject" contextLevel="module" [contextInstanceId]="module && module.id"
[courseId]="courseId">
</core-format-text>
<ion-icon name="fas-lock" *ngIf="discussion.locked" class="addon-mod-forum-locked-icon"
[attr.aria-label]="'addon.mod_forum.discussionlocked' | translate"></ion-icon>
</p>
<div class="addon-mod-forum-discussion-info">
<core-user-avatar *ngIf="discussion.userfullname" [user]="discussion" slot="start" [courseId]="courseId">
<core-user-avatar *ngIf="discussion.userfullname" [user]="discussion" slot="start" [courseId]="courseId"
[linkProfile]="false">
</core-user-avatar>
<div class="addon-mod-forum-discussion-author">
<span *ngIf="discussion.userfullname">{{discussion.userfullname}}</span>
@ -136,6 +132,11 @@
</ion-col>
</ion-row>
</ion-label>
<ion-button *ngIf="canPin || discussion.canlock || discussion.canfavourite" fill="clear"
[attr.aria-label]="('core.displayoptions' | translate)" (click)="showOptionsMenu($event, discussion)" slot="end">
<ion-icon name="ellipsis-vertical" slot="icon-only" aria-hidden="true">
</ion-icon>
</ion-button>
</ion-item>
<core-infinite-loading [enabled]="discussions && discussions.loaded && !discussions.completed" [error]="fetchFailed"

View File

@ -7,7 +7,6 @@
}
.addon-mod-forum-discussion.item {
ion-label {
margin-top: 4px;
@ -35,21 +34,30 @@
@include margin(0, 8px, 0, 0);
}
.addon-mod-forum-discussion-title,
.addon-mod-forum-discussion-info {
display: flex;
align-items: center;
}
.addon-mod-forum-discussion-title .item-heading,
.addon-mod-forum-discussion-info .addon-mod-forum-discussion-author {
flex-grow: 1;
}
.addon-mod-forum-discussion-title {
@include margin-horizontal(null, 8px);
line-height: 18px;
}
.addon-mod-forum-discussion-more-info.ios {
font-size: 0.9rem;
}
ion-button {
position: absolute;
@include position (4px, 8px, null, null);
}
}
.core-group-selector {

View File

@ -126,7 +126,7 @@
<ion-icon *ngIf="advanced" name="fas-chevron-down" slot="start" aria-hidden="true" class="expandable-status-icon">
</ion-icon>
<ion-label>
<h2>{{ 'addon.mod_forum.advanced' | translate }}</h2>
<h3 class="item-heading">{{ 'addon.mod_forum.advanced' | translate }}</h3>
</ion-label>
</ion-item>
<div *ngIf="advanced" [id]="'addon-forum-reply-edit-form-advanced-' + uniqueId">

View File

@ -1,7 +1,7 @@
<ion-header>
<ion-toolbar>
<ion-title>
<h2 id="addon-mod-forum-sort-order-label">{{ 'core.sort' | translate }}</h2>
<h1 id="addon-mod-forum-sort-order-label">{{ 'core.sort' | translate }}</h1>
</ion-title>
<ion-buttons slot="end">
<ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
@ -17,7 +17,7 @@
[attr.aria-current]="selected == sortOrder.value ? 'page' : 'false'" [attr.aria-label]="sortOrder.label | translate"
(click)="selectSortOrder(sortOrder)" button aria-haspopup="dialog">
<ion-label>
<h2>{{ sortOrder.label | translate }}</h2>
<p class="item-heading">{{ sortOrder.label | translate }}</p>
</ion-label>
</ion-item>
</ng-container>

View File

@ -46,10 +46,10 @@
<ion-toggle [(ngModel)]="newDiscussion.postToAllGroups" name="postallgroups"></ion-toggle>
</ion-item>
<ion-item *ngIf="showGroups" class="core-edit-set-group">
<ion-label id="addon-mod-forum-groupslabel">{{ 'addon.mod_forum.group' | translate }}</ion-label>
<ion-select [(ngModel)]="newDiscussion.groupId" [disabled]="newDiscussion.postToAllGroups"
aria-labelledby="addon-mod-forum-groupslabel" interface="action-sheet" name="groupid"
[interfaceOptions]="{header: 'addon.mod_forum.group' | translate}" (ionChange)="calculateGroupName()">
<ion-label>{{ 'addon.mod_forum.group' | translate }}</ion-label>
<ion-select [(ngModel)]="newDiscussion.groupId" [disabled]="newDiscussion.postToAllGroups" interface="action-sheet"
name="groupid" [interfaceOptions]="{header: 'addon.mod_forum.group' | translate}"
(ionChange)="calculateGroupName()">
<ion-select-option *ngFor="let group of groups" [value]="group.id">{{ group.name }}</ion-select-option>
</ion-select>
</ion-item>

View File

@ -28,11 +28,11 @@
</core-rich-text-editor>
</ion-item>
<ion-item *ngIf="categories.length > 0">
<ion-label position="stacked" id="addon-mod-glossary-categories-label">
<ion-label position="stacked">
{{ 'addon.mod_glossary.categories' | translate }}
</ion-label>
<ion-select [(ngModel)]="options.categories" multiple="true" aria-labelledby="addon-mod-glossary-categories-label"
interface="action-sheet" [placeholder]="'addon.mod_glossary.categories' | translate" name="categories"
<ion-select [(ngModel)]="options.categories" multiple="true" interface="action-sheet"
[placeholder]="'addon.mod_glossary.categories' | translate" name="categories"
[interfaceOptions]="{header: 'addon.mod_glossary.categories' | translate}">
<ion-select-option *ngFor="let category of categories" [value]="category.id">
{{ category.name }}
@ -40,11 +40,10 @@
</ion-select>
</ion-item>
<ion-item>
<ion-label position="stacked" id="addon-mod-glossary-aliases-label">
<ion-label position="stacked">
{{ 'addon.mod_glossary.aliases' | translate }}
</ion-label>
<ion-textarea [(ngModel)]="options.aliases" rows="1" [core-auto-rows]="options.aliases"
aria-labelledby="addon-mod-glossary-aliases-label" name="aliases">
<ion-textarea [(ngModel)]="options.aliases" rows="1" [core-auto-rows]="options.aliases" name="aliases">
</ion-textarea>
</ion-item>
<ion-item-divider>

View File

@ -200,6 +200,7 @@ Feature: Test glossary navigation
When I swipe to the left in the app
Then I should find "Acerola is a fruit" in the app
@ci_jenkins_skip
Scenario: Tablet navigation on glossary
Given I entered the course "Course 1" as "student1" in the app
And I change viewport size to "1200x640"

View File

@ -57,7 +57,7 @@
<ion-item class="ion-text-center" *ngIf="downloading">
<ion-label>
<ion-spinner [attr.aria-label]="'core.loading' | translate"></ion-spinner>
<h2 *ngIf="progressMessage">{{ progressMessage | translate }}</h2>
<p class="item-heading" *ngIf="progressMessage">{{ progressMessage | translate }}</p>
<core-progress-bar *ngIf="showPercentage" [progress]="percentage" [a11yText]="progressMessage"></core-progress-bar>
</ion-label>
</ion-item>

View File

@ -23,13 +23,13 @@
[attr.aria-label]="user.fullname">
<core-user-avatar [user]="user" slot="start" [courseId]="courseId" [linkProfile]="false"></core-user-avatar>
<ion-label>
<h2>{{ 'addon.mod_h5pactivity.attempt' | translate }} #{{attempt.attempt}}: {{user.fullname}}</h2>
<p class="item-heading">{{ 'addon.mod_h5pactivity.attempt' | translate }} #{{attempt.attempt}}: {{user.fullname}}</p>
</ion-label>
</ion-item>
<!-- Attempt number (if user not known). -->
<ion-item class="ion-text-wrap" *ngIf="!user">
<ion-label>
<h2>{{ 'addon.mod_h5pactivity.attempt' | translate }} #{{attempt.attempt}}</h2>
<p class="item-heading">{{ 'addon.mod_h5pactivity.attempt' | translate }} #{{attempt.attempt}}</p>
</ion-label>
</ion-item>
@ -38,13 +38,13 @@
<ion-list>
<ion-item class="ion-text-wrap">
<ion-label>
<h2>{{ 'addon.mod_h5pactivity.startdate' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_h5pactivity.startdate' | translate }}</p>
<p>{{ attempt.timecreated | coreFormatDate:'strftimedatetime' }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
<h2>{{ 'addon.mod_h5pactivity.completion' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_h5pactivity.completion' | translate }}</p>
<p *ngIf="attempt.completion">
<img src="assets/img/completion/completion-auto-y.svg" role="presentation" alt="">
{{ 'addon.mod_h5pactivity.attempt_completion_yes' | translate }}
@ -57,13 +57,13 @@
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
<h2>{{ 'addon.mod_h5pactivity.duration' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_h5pactivity.duration' | translate }}</p>
<p>{{ attempt.durationReadable }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
<h2>{{ 'addon.mod_h5pactivity.outcome' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_h5pactivity.outcome' | translate }}</p>
<p *ngIf="attempt.success !== null && attempt.success">
<ion-icon name="fas-check-circle" aria-hidden="true"></ion-icon>
{{ 'addon.mod_h5pactivity.attempt_success_pass' | translate }}
@ -79,7 +79,7 @@
</ion-item>
<ion-item *ngIf="attempt.maxscore" class="ion-text-wrap">
<ion-label>
<h2>{{ 'addon.mod_h5pactivity.totalscore' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_h5pactivity.totalscore' | translate }}</p>
<p>{{ 'addon.mod_h5pactivity.score_out_of' | translate:{$a: attempt} }}</p>
</ion-label>
</ion-item>

View File

@ -37,7 +37,7 @@
<ng-container *ngIf="attemptsData.scored">
<ion-item-divider>
<ion-label>
<h2>{{ attemptsData.scored.title }}</h2>
<h3 class="item-heading">{{ attemptsData.scored.title }}</h3>
</ion-label>
</ion-item-divider>
<ng-container *ngTemplateOutlet="attemptsTemplate; context: {attempts: attemptsData.scored.attempts}">
@ -48,7 +48,7 @@
<ng-container *ngIf="attemptsData.attempts && attemptsData.attempts.length">
<ion-item-divider>
<ion-label>
<h2>{{ 'addon.mod_h5pactivity.all_attempts' | translate }}</h2>
<h3 class="item-heading">{{ 'addon.mod_h5pactivity.all_attempts' | translate }}</h3>
</ion-label>
</ion-item-divider>
<ng-container *ngTemplateOutlet="attemptsTemplate; context: {attempts: attemptsData.attempts}"></ng-container>

View File

@ -1,7 +1,7 @@
<ion-header>
<ion-toolbar>
<ion-title>
<h2>{{ 'addon.mod_imscp.toc' | translate }}</h2>
<h1>{{ 'addon.mod_imscp.toc' | translate }}</h1>
</ion-title>
<ion-buttons slot="end">
<ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate">

View File

@ -77,7 +77,7 @@
<ion-grid class="ion-text-wrap ion-hide-md-down">
<ion-row *ngIf="overview.lessonscored">
<ion-col class="ion-text-center">
<h3 class="item-heading">{{ 'addon.mod_lesson.averagescore' | translate }}</h3>
<p class="item-heading">{{ 'addon.mod_lesson.averagescore' | translate }}</p>
<p *ngIf="overview.numofattempts > 0">
{{ 'core.percentagenumber' | translate:{$a: overview.avescore} }}
</p>
@ -85,7 +85,7 @@
</ion-col>
<ion-col class="ion-text-center">
<h3 class="item-heading">{{ 'addon.mod_lesson.highscore' | translate }}</h3>
<p class="item-heading">{{ 'addon.mod_lesson.highscore' | translate }}</p>
<p *ngIf="overview.highscore != null">
{{ 'core.percentagenumber' | translate:{$a: overview.highscore} }}
</p>
@ -93,7 +93,7 @@
</ion-col>
<ion-col class="ion-text-center">
<h3 class="item-heading">{{ 'addon.mod_lesson.lowscore' | translate }}</h3>
<p class="item-heading">{{ 'addon.mod_lesson.lowscore' | translate }}</p>
<p *ngIf="overview.lowscore != null">
{{ 'core.percentagenumber' | translate:{$a: overview.lowscore} }}
</p>
@ -102,7 +102,7 @@
</ion-row>
<ion-row>
<ion-col class="ion-text-center">
<h3 class="item-heading">{{ 'addon.mod_lesson.averagetime' | translate }}</h3>
<p class="item-heading">{{ 'addon.mod_lesson.averagetime' | translate }}</p>
<p *ngIf="overview.avetime != null && overview.numofattempts">{{ avetimeReadable }}</p>
<p *ngIf="overview.avetime == null || !overview.numofattempts">
{{ 'addon.mod_lesson.notcompleted' | translate }}
@ -110,13 +110,13 @@
</ion-col>
<ion-col class="ion-text-center">
<h3 class="item-heading">{{ 'addon.mod_lesson.hightime' | translate }}</h3>
<p class="item-heading">{{ 'addon.mod_lesson.hightime' | translate }}</p>
<p *ngIf="overview.hightime != null">{{ hightimeReadable }}</p>
<p *ngIf="overview.hightime == null">{{ 'addon.mod_lesson.notcompleted' | translate }}</p>
</ion-col>
<ion-col class="ion-text-center">
<h3 class="item-heading">{{ 'addon.mod_lesson.lowtime' | translate }}</h3>
<p class="item-heading">{{ 'addon.mod_lesson.lowtime' | translate }}</p>
<p *ngIf="overview.lowtime != null">{{ lowtimeReadable }}</p>
<p *ngIf="overview.lowtime == null">{{ 'addon.mod_lesson.notcompleted' | translate }}</p>
</ion-col>
@ -127,7 +127,7 @@
<ion-grid class="ion-text-wrap ion-hide-md-up">
<ion-row>
<ion-col class="ion-text-center" *ngIf="overview.lessonscored">
<h3 class="item-heading">{{ 'addon.mod_lesson.averagescore' | translate }}</h3>
<p class="item-heading">{{ 'addon.mod_lesson.averagescore' | translate }}</p>
<p *ngIf="overview.numofattempts > 0">
{{ 'core.percentagenumber' | translate:{$a: overview.avescore} }}
</p>
@ -135,7 +135,7 @@
</ion-col>
<ion-col [ngClass]="{'ion-text-center': overview.lessonscored}">
<h3 class="item-heading">{{ 'addon.mod_lesson.averagetime' | translate }}</h3>
<p class="item-heading">{{ 'addon.mod_lesson.averagetime' | translate }}</p>
<p *ngIf="overview.avetime != null && overview.numofattempts">{{ avetimeReadable }}</p>
<p *ngIf="overview.avetime == null || !overview.numofattempts">
{{ 'addon.mod_lesson.notcompleted' | translate }}
@ -144,7 +144,7 @@
</ion-row>
<ion-row>
<ion-col class="ion-text-center" *ngIf="overview.lessonscored">
<h3 class="item-heading">{{ 'addon.mod_lesson.highscore' | translate }}</h3>
<p class="item-heading">{{ 'addon.mod_lesson.highscore' | translate }}</p>
<p *ngIf="overview.highscore != null">
{{ 'core.percentagenumber' | translate:{$a: overview.highscore} }}
</p>
@ -152,14 +152,14 @@
</ion-col>
<ion-col [ngClass]="{'ion-text-center': overview.lessonscored}">
<h3 class="item-heading">{{ 'addon.mod_lesson.hightime' | translate }}</h3>
<p class="item-heading">{{ 'addon.mod_lesson.hightime' | translate }}</p>
<p *ngIf="overview.hightime != null">{{ hightimeReadable }}</p>
<p *ngIf="overview.hightime == null">{{ 'addon.mod_lesson.notcompleted' | translate }}</p>
</ion-col>
</ion-row>
<ion-row>
<ion-col class="ion-text-center" *ngIf="overview.lessonscored">
<h3 class="item-heading">{{ 'addon.mod_lesson.lowscore' | translate }}</h3>
<p class="item-heading">{{ 'addon.mod_lesson.lowscore' | translate }}</p>
<p *ngIf="overview.lowscore != null">
{{ 'core.percentagenumber' | translate:{$a: overview.lowscore} }}
</p>
@ -167,7 +167,7 @@
</ion-col>
<ion-col [ngClass]="{'ion-text-center': overview.lessonscored}">
<h3 class="item-heading">{{ 'addon.mod_lesson.lowtime' | translate }}</h3>
<p class="item-heading">{{ 'addon.mod_lesson.lowtime' | translate }}</p>
<p *ngIf="overview.lowtime != null">{{ lowtimeReadable }}</p>
<p *ngIf="overview.lowtime == null">{{ 'addon.mod_lesson.notcompleted' | translate }}</p>
</ion-col>

View File

@ -1,7 +1,7 @@
<ion-header>
<ion-toolbar>
<ion-title>
<h2>{{ pageInstance?.lesson?.name }}</h2>
<h1>{{ pageInstance?.lesson?.name }}</h1>
</ion-title>
<ion-buttons slot="end">
<ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate">

View File

@ -1,7 +1,7 @@
<ion-header>
<ion-toolbar>
<ion-title>
<h2>{{ 'core.login.password' | translate }}</h2>
<h1>{{ 'core.login.password' | translate }}</h1>
</ion-title>
<ion-buttons slot="end">
<ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate">

View File

@ -94,7 +94,7 @@
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="!question.textarea && question.useranswer">
<ion-label>
<h3 class="item-heading">{{ 'addon.mod_lesson.youranswer' | translate }}</h3>
<p class="item-heading">{{ 'addon.mod_lesson.youranswer' | translate }}</p>
<p>
<core-format-text [component]="component" [componentId]="lesson?.coursemodule"
[text]="question.useranswer" contextLevel="module" [contextInstanceId]="lesson?.coursemodule"
@ -138,14 +138,12 @@
<ion-item class="ion-text-wrap" *ngFor="let row of question.rows">
<ion-label>
<p>
<core-format-text id="addon-mod_lesson-matching-{{row.id}}" [component]="component"
[componentId]="lesson?.coursemodule" [text]="row.text" contextLevel="module"
[contextInstanceId]="lesson?.coursemodule" [courseId]="courseId">
<core-format-text [component]="component" [componentId]="lesson?.coursemodule" [text]="row.text"
contextLevel="module" [contextInstanceId]="lesson?.coursemodule" [courseId]="courseId">
</core-format-text>
</p>
</ion-label>
<ion-select [id]="row.id" [formControlName]="row.name" interface="action-sheet"
[attr.aria-labelledby]="'addon-mod_lesson-matching-' + row.id">
<ion-select [id]="row.id" [formControlName]="row.name" interface="action-sheet">
<ion-select-option *ngFor="let option of row.options" [value]="option.value">
{{option.label}}
</ion-select-option>

View File

@ -27,9 +27,8 @@
<!-- Retake selector if there is more than one retake. -->
<ion-item class="ion-text-wrap" *ngIf="student.attempts && student.attempts.length > 1">
<ion-label id="addon-mod_lesson-retakeslabel">{{ 'addon.mod_lesson.attemptheader' | translate }}</ion-label>
<ion-select [(ngModel)]="selectedRetake" (ionChange)="changeRetake(selectedRetake!)"
aria-labelledby="addon-mod_lesson-retakeslabel" interface="action-sheet"
<ion-label>{{ 'addon.mod_lesson.attemptheader' | translate }}</ion-label>
<ion-select [(ngModel)]="selectedRetake" (ionChange)="changeRetake(selectedRetake!)" interface="action-sheet"
[interfaceOptions]="{header: 'addon.mod_lesson.attemptheader' | translate}">
<ion-select-option *ngFor="let retake of student.attempts" [value]="retake.try">
{{retake.label}}
@ -44,12 +43,12 @@
<ion-grid class="ion-no-padding">
<ion-row>
<ion-col>
<h3 class="item-heading">{{ 'addon.mod_lesson.grade' | translate }}</h3>
<p class="item-heading">{{ 'addon.mod_lesson.grade' | translate }}</p>
<p>{{ 'core.percentagenumber' | translate:{$a: retake.userstats.grade} }}</p>
</ion-col>
<ion-col>
<h3 class="item-heading">{{ 'addon.mod_lesson.rawgrade' | translate }}</h3>
<p class="item-heading">{{ 'addon.mod_lesson.rawgrade' | translate }}</p>
<p>{{ retake.userstats.gradeinfo.earned }} / {{ retake.userstats.gradeinfo.total }}</p>
</ion-col>
</ion-row>
@ -58,13 +57,13 @@
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
<h3 class="item-heading">{{ 'addon.mod_lesson.timetaken' | translate }}</h3>
<p class="item-heading">{{ 'addon.mod_lesson.timetaken' | translate }}</p>
<p>{{ timeTakenReadable }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
<h3 class="item-heading">{{ 'addon.mod_lesson.completed' | translate }}</h3>
<p class="item-heading">{{ 'addon.mod_lesson.completed' | translate }}</p>
<p>{{ retake.userstats.completed * 1000 | coreFormatDate }}</p>
</ion-label>
</ion-item>
@ -85,7 +84,7 @@
</ion-card-header>
<ion-item class="ion-text-wrap">
<ion-label>
<h3 class="item-heading">{{ 'addon.mod_lesson.question' | translate }}</h3>
<p class="item-heading">{{ 'addon.mod_lesson.question' | translate }}</p>
<p>
<core-format-text [component]="component" [componentId]="lesson?.coursemodule" collapsible-item
[text]="page.contents" contextLevel="module" [contextInstanceId]="lesson?.coursemodule"
@ -96,7 +95,7 @@
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
<h3 class="item-heading">{{ 'addon.mod_lesson.answer' | translate }}</h3>
<p class="item-heading">{{ 'addon.mod_lesson.answer' | translate }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="!page.answerdata || !page.answerdata.answers || !page.answerdata.answers.length">
@ -227,7 +226,7 @@
<ion-item class="ion-text-wrap" *ngIf="page.answerdata.response">
<ion-label>
<h3 class="item-heading">{{ 'addon.mod_lesson.response' | translate }}</h3>
<p class="item-heading">{{ 'addon.mod_lesson.response' | translate }}</p>
<p>
<core-format-text [component]="component" [componentId]="lesson?.coursemodule"
[text]="page.answerdata.response" contextLevel="module" [contextInstanceId]="lesson?.coursemodule"

View File

@ -1,6 +1,6 @@
<ion-item class="ion-text-wrap">
<ion-label>
<h3 class="item-heading">{{ 'core.settings.synchronization' | translate }}</h3>
<p class="item-heading">{{ 'core.settings.synchronization' | translate }}</p>
<p>{{ 'addon.mod_quiz.confirmcontinueoffline' | translate:{$a: syncTimeReadable} }}</p>
</ion-label>
</ion-item>

View File

@ -1,6 +1,6 @@
<ion-item class="ion-text-wrap">
<ion-label>
<h3 class="item-heading">{{ 'addon.mod_quiz.quizpassword' | translate }}</h3>
<p class="item-heading">{{ 'addon.mod_quiz.quizpassword' | translate }}</p>
<p>{{ 'addon.mod_quiz.requirepasswordmessage' | translate}}</p>
</ion-label>
</ion-item>

View File

@ -1,6 +1,6 @@
<ion-item class="ion-text-wrap">
<ion-label>
<h3 class="item-heading">{{ 'addon.mod_quiz.confirmstartheader' | translate }}</h3>
<p class="item-heading">{{ 'addon.mod_quiz.confirmstartheader' | translate }}</p>
<p>{{ 'addon.mod_quiz.confirmstart' | translate:{$a: readableTimeLimit} }}</p>
</ion-label>
</ion-item>

View File

@ -1,7 +1,7 @@
<ion-header>
<ion-toolbar>
<ion-title>
<h2>{{ 'addon.mod_quiz.quiznavigation' | translate }}</h2>
<h1>{{ 'addon.mod_quiz.quiznavigation' | translate }}</h1>
</ion-title>
<ion-buttons slot="end">
<ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate">

View File

@ -1,7 +1,7 @@
<ion-header>
<ion-toolbar>
<ion-title>
<h2>{{ title | translate }}</h2>
<h1>{{ title | translate }}</h1>
</ion-title>
<ion-buttons slot="end">
<ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate">

View File

@ -20,32 +20,32 @@
<ion-list *ngIf="attempt">
<ion-item class="ion-text-wrap">
<ion-label>
<h2>{{ 'addon.mod_quiz.attemptnumber' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_quiz.attemptnumber' | translate }}</p>
<p *ngIf="attempt.preview">{{ 'addon.mod_quiz.preview' | translate }}</p>
<p *ngIf="!attempt.preview">{{ attempt.attempt }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
<h2>{{ 'addon.mod_quiz.attemptstate' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_quiz.attemptstate' | translate }}</p>
<p *ngFor="let sentence of attempt.readableState">{{ sentence }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="quiz!.showMarkColumn && attempt.readableMark !== ''">
<ion-label>
<h2>{{ 'addon.mod_quiz.marks' | translate }} / {{ quiz!.sumGradesFormatted }}</h2>
<p class="item-heading">{{ 'addon.mod_quiz.marks' | translate }} / {{ quiz!.sumGradesFormatted }}</p>
<p>{{ attempt.readableMark }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="quiz!.showGradeColumn && attempt.readableGrade !== ''">
<ion-label>
<h2>{{ 'addon.mod_quiz.grade' | translate }} / {{ quiz!.gradeFormatted }}</h2>
<p class="item-heading">{{ 'addon.mod_quiz.grade' | translate }} / {{ quiz!.gradeFormatted }}</p>
<p>{{ attempt.readableGrade }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="quiz!.showFeedbackColumn && feedback">
<ion-label>
<h2>{{ 'addon.mod_quiz.feedback' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_quiz.feedback' | translate }}</p>
<p>
<core-format-text [component]="component" [componentId]="componentId" [text]="feedback" contextLevel="module"
[contextInstanceId]="cmId" [courseId]="courseId">

View File

@ -132,7 +132,7 @@
<!-- List of messages explaining why the quiz cannot be submitted. -->
<ion-item class="ion-text-wrap" *ngIf="preventSubmitMessages.length">
<ion-label>
<h3 class="item-heading">{{ 'addon.mod_quiz.cannotsubmitquizdueto' | translate }}</h3>
<p class="item-heading">{{ 'addon.mod_quiz.cannotsubmitquizdueto' | translate }}</p>
<p *ngFor="let message of preventSubmitMessages">{{message}}</p>
</ion-label>
</ion-item>

View File

@ -47,7 +47,7 @@ import { CanLeave } from '@guards/can-leave';
import { CoreForms } from '@singletons/form';
import { CoreDom } from '@singletons/dom';
import { CoreTime } from '@singletons/time';
import { CoreComponentsRegistry } from '@singletons/components-registry';
import { CoreDirectivesRegistry } from '@singletons/directives-registry';
/**
* Page that allows attempting a quiz.
@ -690,7 +690,7 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy, CanLeave {
*/
protected async scrollToQuestion(slot: number): Promise<void> {
await CoreUtils.nextTick();
await CoreComponentsRegistry.waitComponentsReady(this.elementRef.nativeElement, 'core-question');
await CoreDirectivesRegistry.waitDirectivesReady(this.elementRef.nativeElement, 'core-question');
await CoreDom.scrollToElement(
this.elementRef.nativeElement,
'#addon-mod_quiz-question-' + slot,

View File

@ -26,43 +26,43 @@
<ion-list>
<ion-item class="ion-text-wrap">
<ion-label>
<h2>{{ 'addon.mod_quiz.startedon' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_quiz.startedon' | translate }}</p>
<p>{{ attempt.timestart! * 1000 | coreFormatDate }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
<h2>{{ 'addon.mod_quiz.attemptstate' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_quiz.attemptstate' | translate }}</p>
<p>{{ readableState }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="showCompleted">
<ion-label>
<h2>{{ 'addon.mod_quiz.completedon' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_quiz.completedon' | translate }}</p>
<p>{{ attempt.timefinish! * 1000 | coreFormatDate }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="timeTaken">
<ion-label>
<h2>{{ 'addon.mod_quiz.timetaken' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_quiz.timetaken' | translate }}</p>
<p>{{ timeTaken }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="overTime">
<ion-label>
<h2>{{ 'addon.mod_quiz.overdue' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_quiz.overdue' | translate }}</p>
<p>{{ overTime }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="readableMark">
<ion-label>
<h2>{{ 'addon.mod_quiz.marks' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_quiz.marks' | translate }}</p>
<p>{{ readableMark }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="readableGrade">
<ion-label>
<h2>{{ 'addon.mod_quiz.grade' | translate }}</h2>
<p class="item-heading">{{ 'addon.mod_quiz.grade' | translate }}</p>
<p>{{ readableGrade }}</p>
</ion-label>
</ion-item>

Some files were not shown because too many files have changed in this diff Show More