Merge pull request #3897 from moodlehq/ionic7

MOBILE-3947: Upgrade to Ionic 7
main
Dani Palou 2024-01-17 17:02:13 +01:00 committed by GitHub
commit a9f6d2ca94
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
685 changed files with 30285 additions and 33349 deletions

View File

@ -5,6 +5,7 @@ const appConfig = {
node: true,
},
plugins: [
'@angular-eslint',
'@typescript-eslint',
'header',
'jsdoc',
@ -13,12 +14,13 @@ const appConfig = {
],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended',
'prettier',
'plugin:@angular-eslint/recommended',
'plugin:@angular-eslint/template/process-inline-templates',
'plugin:promise/recommended',
'plugin:jsdoc/recommended',
"plugin:deprecation/recommended",
'plugin:deprecation/recommended',
],
parser: '@typescript-eslint/parser',
parserOptions: {
@ -46,6 +48,7 @@ const appConfig = {
Object: {
message: 'Use {} instead.',
},
Function: false,
},
},
],
@ -61,16 +64,6 @@ const appConfig = {
allowArgumentsExplicitlyTypedAsAny: true,
},
],
'@typescript-eslint/indent': [
'error',
4,
{
SwitchCase: 1,
ignoredNodes: [
'ClassProperty *',
],
},
],
'@typescript-eslint/lines-between-class-members': [
'error',
'always',
@ -103,6 +96,20 @@ const appConfig = {
],
'@typescript-eslint/naming-convention': [
'error',
{
selector: [
'classProperty',
'objectLiteralProperty',
'typeProperty',
'classMethod',
'objectLiteralMethod',
'typeMethod',
'accessor',
'enumMember'
],
modifiers: ['requiresQuotes'],
format: null,
},
{
selector: 'property',
format: ['camelCase'],
@ -200,17 +207,6 @@ const appConfig = {
],
'id-match': 'error',
'jsdoc/check-alignment': 'error',
'jsdoc/newline-after-description': 'error',
'jsdoc/require-param-type': 'off',
'jsdoc/require-returns-type': 'off',
'jsdoc/require-param': 'off',
'jsdoc/check-values': 'off',
'jsdoc/check-tag-names': [
'warn',
{
"definedTags": ["deprecatedonmoodle"]
},
],
'jsdoc/check-param-names': [
'warn',
{
@ -218,6 +214,23 @@ const appConfig = {
enableFixer: true
},
],
'jsdoc/check-tag-names': [
'warn',
{
'definedTags': ['deprecatedonmoodle']
},
],
'jsdoc/check-values': 'off',
'jsdoc/require-param-type': 'off',
'jsdoc/require-param': 'off',
'jsdoc/require-returns-type': 'off',
'jsdoc/tag-lines': [
'error',
'any',
{
startLines: 1,
},
],
'linebreak-style': [
'error',
'unix',
@ -240,7 +253,7 @@ const appConfig = {
'no-fallthrough': 'off',
'no-invalid-this': 'error',
'no-irregular-whitespace': 'error',
'no-multiple-empty-lines': ['error', { "max": 1 }],
'no-multiple-empty-lines': ['error', { max: 1 }],
'no-new-wrappers': 'error',
'no-sequences': 'error',
'no-trailing-spaces': 'error',
@ -318,15 +331,15 @@ module.exports = {
files: ['*.html'],
extends: ['plugin:@angular-eslint/template/recommended'],
rules: {
'max-len': ['warn', { code: 140 }],
'@angular-eslint/template/accessibility-valid-aria': 'warn',
'@angular-eslint/template/accessibility-alt-text': 'error',
'@angular-eslint/template/accessibility-elements-content': 'error',
'@angular-eslint/template/accessibility-label-for': 'error',
'@angular-eslint/template/no-positive-tabindex': 'error',
'@angular-eslint/template/accessibility-table-scope': 'error',
'@angular-eslint/template/accessibility-valid-aria': 'error',
'@angular-eslint/template/alt-text': 'error',
'@angular-eslint/template/elements-content': 'error',
'@angular-eslint/template/label-has-associated-control': 'error',
'@angular-eslint/template/no-duplicate-attributes': 'error',
'@angular-eslint/template/no-positive-tabindex': 'error',
'@angular-eslint/template/prefer-self-closing-tags': 'error',
'@angular-eslint/template/table-scope': 'error',
'@angular-eslint/template/valid-aria': 'error',
'max-len': ['warn', { code: 140 }],
},
},
{

View File

@ -10,13 +10,13 @@ on:
moodle_branch:
description: 'Moodle branch'
required: true
default: 'master'
default: 'main'
moodle_repository:
description: 'Moodle repository'
required: true
default: 'https://github.com/moodle/moodle'
pull_request:
branches: [ main, v*.x ]
branches: [ main, ionic7, v*.x ]
jobs:
behat:
@ -24,8 +24,8 @@ jobs:
env:
MOODLE_DOCKER_DB: pgsql
MOODLE_DOCKER_BROWSER: chrome
MOODLE_DOCKER_PHP_VERSION: '8.0'
MOODLE_BRANCH: ${{ github.event.inputs.moodle_branch || 'master' }}
MOODLE_DOCKER_PHP_VERSION: '8.1'
MOODLE_BRANCH: ${{ github.event.inputs.moodle_branch || 'main' }}
MOODLE_REPOSITORY: ${{ github.event.inputs.moodle_repository || 'https://github.com/moodle/moodle' }}
BEHAT_TAGS: ${{ github.event.inputs.behat_tags || '~@performance' }}
@ -37,7 +37,7 @@ jobs:
- name: Additional checkouts
run: |
git clone --branch $MOODLE_BRANCH --depth 1 $MOODLE_REPOSITORY $GITHUB_WORKSPACE/moodle
git clone --branch master --depth 1 https://github.com/moodlehq/moodle-docker $GITHUB_WORKSPACE/moodle-docker
git clone --branch main --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

View File

@ -16,8 +16,8 @@ jobs:
node-version-file: '.nvmrc'
- name: Additional checkouts
run: |
git clone --branch master --depth 1 https://github.com/moodle/moodle $GITHUB_WORKSPACE/moodle
git clone --branch master --depth 1 https://github.com/moodlehq/moodle-docker $GITHUB_WORKSPACE/moodle-docker
git clone --branch main --depth 1 https://github.com/moodle/moodle $GITHUB_WORKSPACE/moodle
git clone --branch main --depth 1 https://github.com/moodlehq/moodle-docker $GITHUB_WORKSPACE/moodle-docker
- name: Install npm packages
run: npm ci --no-audit
- name: Generate Behat tests plugin

View File

@ -8,8 +8,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v3
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
- name: Install npm packages
@ -59,4 +59,8 @@ jobs:
npm run build:prod
npm run prod --prefix cordova-plugin-moodleapp
- name: JavaScript code compatibility
run: result=$(npx check-es-compat www/*.js 2> /dev/null | grep -v -E "Array\.prototype\.includes|Promise\.prototype\.finally|String\.prototype\.(matchAll|trimRight)|globalThis" | grep -Po "(?<=error).*?(?=\s+ecmascript)" | wc -l); test $result -eq 1
# 6 BigInt usage errors are expected, they are fine without polyfill because they are only used if available.
# See https://github.com/videojs/mpd-parser/blob/v0.22.1/src/segment/urlType.js
run: |
result=$(npx check-es-compat www/*.js --polyfills="{Array,String,TypedArray}.prototype.at,Array.prototype.flatMap,Array.prototype.flat,Array.prototype.includes,globalThis,Object.fromEntries,Object.hasOwn,Promise.prototype.finally,String.prototype.matchAll,String.prototype.trimRight" | grep "6 problems (6 errors, 0 warnings)" | wc -l); test $result -eq 1
npx check-es-compat cordova-plugin-moodleapp/www/*.js

1
.gitignore vendored
View File

@ -70,6 +70,7 @@ Thumbs.db
/src/assets/lib
/src/assets/lang/*
/src/assets/env.json
/src/assets/fonts/icons.json
/moodle.config.*.json
!/moodle.config.example.json

2
.nvmrc
View File

@ -1 +1 @@
v14.15.0
v18.18.2

View File

@ -9,12 +9,12 @@
},
"editor.formatOnSave": true,
"eslint.format.enable": true,
"html.format.endWithNewline": true,
"html.format.wrapLineLength": 140,
"files.eol": "\n",
"files.trimFinalNewlines": true,
"files.insertFinalNewline": true,
"files.trimTrailingWhitespace": true,
"typescript.tsdk": "./node_modules/typescript/lib",
/**
* Config files.

View File

@ -1,5 +1,5 @@
## BUILD STAGE
FROM node:14 as build-stage
FROM node:18 as build-stage
WORKDIR /app

View File

@ -1,56 +0,0 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Based on the template node_modules/cordova-android/bin/templates/project/Activity.java
package com.moodle.moodlemobile;
import android.os.Bundle;
import android.view.KeyEvent;
import android.view.View;
import org.apache.cordova.*;
public class MainActivity extends CordovaActivity
{
@Override
public void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
// enable Cordova apps to be started in the background
Bundle extras = getIntent().getExtras();
if (extras != null && extras.getBoolean("cdvStartInBackground", false)) {
moveTaskToBack(true);
}
// Set by <content src="index.html" /> in config.xml
loadUrl(launchUrl);
}
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
// Forward back key events to the web view.
if (this.appView != null && event.getKeyCode() == KeyEvent.KEYCODE_BACK) {
View webview = this.appView.getView();
if (webview != null) {
webview.dispatchKeyEvent(event);
}
return true;
}
return super.dispatchKeyEvent(event);
}
}

View File

@ -1,7 +1,6 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"defaultProject": "app",
"newProjectRoot": "projects",
"projects": {
"app": {
@ -18,7 +17,7 @@
"path": "./webpack.config.js"
},
"allowedCommonJsDependencies":[
"chart.js"
"chart.js"
],
"outputPath": "www",
"index": "src/index.html",
@ -63,12 +62,6 @@
},
"outputHashing": "all",
"sourceMap": false,
"extractCss": true,
"namedChunks": false,
"aot": true,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true,
"budgets": [
{
"type": "initial",
@ -77,24 +70,25 @@
}
]
},
"development": {
"buildOptimizer": false,
"optimization": false,
"vendorChunk": true,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true
},
"testing": {
"optimization": {
"scripts": false,
"styles": true
},
"outputHashing": "all",
"sourceMap": false,
"extractCss": true,
"namedChunks": false,
"aot": true,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true
}
},
"ci": {
"progress": false
}
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
@ -107,10 +101,11 @@
"production": {
"browserTarget": "app:build:production"
},
"ci": {
"progress": false
"development": {
"browserTarget": "app:build:development"
}
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
@ -122,14 +117,14 @@
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": [
"src/**/*.ts",
"src/core/**/*.html",
"src/addons/**/*.html"
"src/**/*.ts",
"src/core/**/*.html",
"src/addons/**/*.html"
]
}
},
"ionic-cordova-build": {
"builder": "@ionic/angular-toolkit:cordova-build",
"builder": "@ionic/cordova-builders:cordova-build",
"options": {
"browserTarget": "app:build"
},
@ -140,7 +135,7 @@
}
},
"ionic-cordova-serve": {
"builder": "@ionic/angular-toolkit:cordova-serve",
"builder": "@ionic/cordova-builders:cordova-serve",
"options": {
"cordovaBuildTarget": "app:ionic-cordova-build",
"devServerTarget": "app:serve"
@ -157,7 +152,9 @@
},
"cli": {
"analytics": false,
"defaultCollection": "@ionic/angular-toolkit"
"schematicCollections": [
"@ionic/angular-toolkit"
]
},
"schematics": {
"@ionic/angular-toolkit:component": {

View File

@ -1,5 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<widget android-versionCode="44000" id="com.moodle.moodlemobile" ios-CFBundleVersion="4.4.0.0" version="4.4.0" versionCode="44000" 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 id="com.moodle.moodlemobile" version="4.4.0" versionCode="44000" android-versionCode="44000" ios-CFBundleVersion="4.4.0.0" 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>
@ -60,7 +60,6 @@
<preference name="AndroidWindowSplashScreenAnimatedIcon" value="resources/android/android-splash.xml" />
<preference name="AndroidWindowSplashScreenBackground" value="#FFFFFF" />
<preference name="AndroidWindowSplashScreenIconBackgroundColor" value="#FFFFFF" />
<resource-file src="MainActivity.java" target="app/src/main/java/com/moodle/moodlemobile/MainActivity.java" />
<resource-file src="google-services.json" target="app/google-services.json" />
<resource-file src="resources/android/icon/drawable-ldpi-smallicon.png" target="app/src/main/res/mipmap-ldpi/smallicon.png" />
<resource-file src="resources/android/icon/drawable-mdpi-smallicon.png" target="app/src/main/res/mipmap-mdpi/smallicon.png" />
@ -68,136 +67,16 @@
<resource-file src="resources/android/icon/drawable-xhdpi-smallicon.png" target="app/src/main/res/mipmap-xhdpi/smallicon.png" />
<resource-file src="resources/android/xml/network_security_config.xml" target="app/src/main/res/xml/network_security_config.xml" />
<resource-file src="resources/android/xml/backup_rules.xml" target="app/src/main/res/xml/backup_rules.xml" />
<edit-config file="AndroidManifest.xml" mode="merge" target="/manifest/application/activity[@android:name='MainActivity']">
<activity android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|screenLayout|smallestScreenSize" android:exported="true" />
</edit-config>
<edit-config file="AndroidManifest.xml" mode="merge" target="/manifest/application">
<application android:allowBackup="true" android:dataExtractionRules="@xml/backup_rules" android:largeHeap="true" android:networkSecurityConfig="@xml/network_security_config" />
</edit-config>
<config-file parent="/manifest/application" target="AndroidManifest.xml">
<meta-data android:name="firebase_analytics_collection_deactivated" android:value="true" />
</config-file>
<config-file parent="/*" target="res/xml/config.xml">
<feature name="Clipboard">
<param name="android-package" value="com.verso.cordova.clipboard.Clipboard" />
</feature>
</config-file>
<config-file parent="/*" target="res/xml/config.xml">
<feature name="CordovaHttpPlugin">
<param name="android-package" value="com.silkimen.cordovahttp.CordovaHttpPlugin" />
</feature>
</config-file>
<config-file parent="/*" target="res/xml/config.xml">
<feature name="Camera">
<param name="android-package" value="org.apache.cordova.camera.CameraLauncher" />
</feature>
</config-file>
<config-file parent="/*" target="res/xml/config.xml">
<feature name="LaunchMyApp">
<param name="android-package" value="nl.xservices.plugins.LaunchMyApp" />
</feature>
</config-file>
<config-file parent="/*" target="res/xml/config.xml">
<feature name="Device">
<param name="android-package" value="org.apache.cordova.device.Device" />
</feature>
</config-file>
<config-file parent="/*" target="res/xml/config.xml">
<feature name="File">
<param name="android-package" value="org.apache.cordova.file.FileUtils" />
<param name="onload" value="true" />
</feature>
<allow-navigation href="cdvfile:*" />
</config-file>
<config-file parent="/*" target="res/xml/config.xml">
<feature name="FileOpener2">
<param name="android-package" value="io.github.pwlin.cordova.plugins.fileopener2.FileOpener2" />
</feature>
</config-file>
<config-file parent="/*" target="res/xml/config.xml">
<feature name="FileTransfer">
<param name="android-package" value="org.apache.cordova.filetransfer.FileTransfer" />
</feature>
</config-file>
<config-file parent="/*" target="res/xml/config.xml">
<feature name="Geolocation">
<param name="android-package" value="org.apache.cordova.geolocation.Geolocation" />
</feature>
</config-file>
<config-file parent="/*" target="res/xml/config.xml">
<feature name="InAppBrowser">
<param name="android-package" value="org.apache.cordova.inappbrowser.InAppBrowser" />
</feature>
</config-file>
<config-file parent="/*" target="res/xml/config.xml">
<feature name="LocalNotification">
<param name="android-package" value="de.appplant.cordova.plugin.localnotification.LocalNotification" />
</feature>
</config-file>
<config-file parent="/manifest/application" target="AndroidManifest.xml">
<provider android:authorities="${applicationId}.localnotifications.provider" android:exported="false" android:grantUriPermissions="true" android:name="de.appplant.cordova.plugin.notification.util.AssetProvider">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/localnotification_provider_paths" />
</provider>
<receiver android:exported="false" android:name="de.appplant.cordova.plugin.localnotification.TriggerReceiver" />
<receiver android:exported="false" android:name="de.appplant.cordova.plugin.localnotification.ClearReceiver" />
<service android:exported="false" android:name="de.appplant.cordova.plugin.localnotification.ClickReceiver" />
<receiver android:directBootAware="true" android:exported="false" android:name="de.appplant.cordova.plugin.localnotification.RestoreReceiver">
<intent-filter>
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</config-file>
<config-file parent="/*" target="res/xml/config.xml">
<feature name="Capture">
<param name="android-package" value="org.apache.cordova.mediacapture.Capture" />
</feature>
</config-file>
<config-file parent="/*" target="res/xml/config.xml">
<feature name="NetworkStatus">
<param name="android-package" value="org.apache.cordova.networkinformation.NetworkManager" />
</feature>
</config-file>
<config-file parent="/*" target="res/xml/config.xml">
<feature name="QRScanner">
<param name="android-package" value="com.bitpay.cordova.qrscanner.QRScanner" />
</feature>
</config-file>
<config-file parent="/*" target="res/xml/config.xml">
<feature name="CDVOrientation">
<param name="android-package" value="cordova.plugins.screenorientation.CDVOrientation" />
</feature>
</config-file>
<config-file parent="/*" target="res/xml/config.xml">
<feature name="StatusBar">
<param name="android-package" value="org.apache.cordova.statusbar.StatusBar" />
<param name="onload" value="true" />
</feature>
</config-file>
<config-file parent="/*" target="res/xml/config.xml">
<feature name="SQLitePlugin">
<param name="android-package" value="io.sqlc.SQLitePlugin" />
</feature>
</config-file>
<config-file parent="/*" target="res/xml/config.xml">
<feature name="PushNotification">
<param name="android-package" value="com.adobe.phonegap.push.PushPlugin" />
</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>
<intent>
<action android:name="android.media.action.IMAGE_CAPTURE" />
</intent>
<intent>
<action android:name="android.intent.action.GET_CONTENT" />
</intent>
</queries>
</config-file>
</platform>
<platform name="ios">
<resource-file src="GoogleService-Info.plist" />

File diff suppressed because it is too large Load Diff

View File

@ -1,69 +0,0 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
const fs = require('fs');
const DEV_CONFIG_FILE = '.moodleapp-dev-config';
/**
* Class to read and write dev-config data from a file.
*/
class DevConfig {
constructor() {
this.loadFileData();
}
/**
* Get a setting.
*
* @param name Name of the setting to get.
* @param defaultValue Value to use if not found.
*/
get(name, defaultValue) {
return typeof this.config[name] != 'undefined' ? this.config[name] : defaultValue;
}
/**
* Load file data to memory.
*/
loadFileData() {
if (!fs.existsSync(DEV_CONFIG_FILE)) {
this.config = {};
return;
}
try {
this.config = JSON.parse(fs.readFileSync(DEV_CONFIG_FILE));
} catch (error) {
console.error('Error reading dev config file.', error);
this.config = {};
}
}
/**
* Save some settings.
*
* @param settings Object with the settings to save.
*/
save(settings) {
this.config = Object.assign(this.config, settings);
// Save the data in the dev file.
fs.writeFileSync(DEV_CONFIG_FILE, JSON.stringify(this.config, null, 4));
}
}
module.exports = new DevConfig();

View File

@ -1,237 +0,0 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
const exec = require('child_process').exec;
const fs = require('fs');
const DevConfig = require('./dev-config');
const Utils = require('./utils');
/**
* Class to run git commands.
*/
class Git {
/**
* Create a patch.
*
* @param range Show only commits in the specified revision range.
* @param saveTo Path to the file to save the patch to. If not defined, the patch contents will be returned.
* @return Promise resolved when done. If saveTo not provided, it will return the patch contents.
*/
createPatch(range, saveTo) {
return new Promise((resolve, reject) => {
exec(`git format-patch ${range} --stdout`, (err, result) => {
if (err) {
reject(err);
return;
}
if (!saveTo) {
resolve(result);
return;
}
// Save it to a file.
const directory = saveTo.substring(0, saveTo.lastIndexOf('/'));
if (directory && directory != '.' && directory != '..' && !fs.existsSync(directory)) {
fs.mkdirSync(directory);
}
fs.writeFileSync(saveTo, result);
resolve();
});
});
}
/**
* Get current branch.
*
* @return Promise resolved with the branch name.
*/
getCurrentBranch() {
return new Promise((resolve, reject) => {
exec('git branch --show-current', (err, branch) => {
if (branch) {
resolve(branch.replace('\n', ''));
} else {
reject (err || 'Current branch not found.');
}
});
});
}
/**
* Get the HEAD commit for a certain branch.
*
* @param branch Name of the branch.
* @param branchData Parsed branch data. If not provided it will be calculated.
* @return HEAD commit.
*/
async getHeadCommit(branch, branchData) {
if (!branchData) {
// Parse the branch to get the project and issue number.
branchData = Utils.parseBranch(branch);
}
// Loop over the last commits to find the first commit messages that doesn't belong to the issue.
const commitsString = await this.log(50, branch, '%s_____%H');
const commits = commitsString.split('\n');
commits.pop(); // Remove last element, it's an empty string.
for (let i = 0; i < commits.length; i++) {
const commit = commits[i];
const match = Utils.getIssueFromCommitMessage(commit) == branchData.issue;
if (i === 0 && !match) {
// Most recent commit doesn't belong to the issue. Stop looking.
break;
}
if (!match) {
// The commit does not match any more, we found it!
return commit.split('_____')[1];
}
}
// Couldn't find the commit using the commit names, get the last commit in the integration branch.
const remote = DevConfig.get('upstreamRemote', 'origin');
console.log(`Head commit not found using commit messages. Get last commit from ${remote}/integration`);
const hashes = await this.hashes(1, `${remote}/integration`);
return hashes[0];
}
/**
* Get the URL of a certain remote.
*
* @param remote Remote name.
* @return Promise resolved with the remote URL.
*/
getRemoteUrl(remote) {
return new Promise((resolve, reject) => {
exec(`git remote get-url ${remote}`, (err, url) => {
if (url) {
resolve(url.replace('\n', ''));
} else {
reject (err || 'Remote not found.');
}
});
});
}
/**
* Return the latest hashes from git log.
*
* @param count Number of commits to display.
* @param range Show only commits in the specified revision range.
* @param format Pretty-print the contents of the commit logs in a given format.
* @return Promise resolved with the list of hashes.
*/
async hashes(count, range, format) {
format = format || '%H';
const hashList = await this.log(count, range, format);
const hashes = hashList.split('\n');
hashes.pop(); // Remove last element, it's an empty string.
return hashes;
}
/**
* Calls the log command and returns the raw output.
*
* @param count Number of commits to display.
* @param range Show only commits in the specified revision range.
* @param format Pretty-print the contents of the commit logs in a given format.
* @param path Show only commits that are enough to explain how the files that match the specified paths came to be.
* @return Promise resolved with the result.
*/
log(count, range, format, path) {
if (typeof count == 'undefined') {
count = 10;
}
let command = 'git log';
if (count > 0) {
command += ` -n ${count} `;
}
if (format) {
command += ` --format=${format} `;
}
if (range){
command += ` ${range} `;
}
if (path) {
command += ` -- ${path}`;
}
return new Promise((resolve, reject) => {
exec(command, (err, result, stderr) => {
if (err) {
reject(err);
} else {
resolve(result);
}
});
});
}
/**
* Return the latest titles of the commit messages.
*
* @param count Number of commits to display.
* @param range Show only commits in the specified revision range.
* @param path Show only commits that are enough to explain how the files that match the specified paths came to be.
* @return Promise resolved with the list of titles.
*/
async messages(count, range, path) {
count = typeof count != 'undefined' ? count : 10;
const messageList = await this.log(count, range, '%s', path);
const messages = messageList.split('\n');
messages.pop(); // Remove last element, it's an empty string.
return messages;
}
/**
* Push a branch.
*
* @param remote Remote to use.
* @param branch Branch to push.
* @param force Whether to force the push.
* @return Promise resolved when done.
*/
push(remote, branch, force) {
return new Promise((resolve, reject) => {
let command = `git push ${remote} ${branch}`;
if (force) {
command += ' -f';
}
exec(command, (err, result, stderr) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
}
module.exports = new Git();

View File

@ -1,476 +0,0 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
const exec = require('child_process').exec;
const https = require('https');
const inquirer = require('inquirer');
const fs = require('fs');
const request = require('request'); // This lib is deprecated, but it was the only way I found to make upload files work.
const DevConfig = require('./dev-config');
const Git = require('./git');
const Url = require('./url');
const Utils = require('./utils');
const apiVersion = 2;
/**
* Class to interact with Jira.
*/
class Jira {
/**
* Ask the password to the user.
*
* @return Promise resolved with the password.
*/
async askPassword() {
const data = await inquirer.prompt([
{
type: 'password',
name: 'password',
message: `Please enter the password for the username ${this.username}.`,
},
]);
return data.password;
}
/**
* Ask the user the tracker data.
*
* @return Promise resolved with the data, rejected if cannot get.
*/
async askTrackerData() {
const data = await inquirer.prompt([
{
type: 'input',
name: 'url',
message: 'Please enter the tracker URL.',
default: 'https://tracker.moodle.org/',
},
{
type: 'input',
name: 'username',
message: 'Please enter your tracker username.',
},
]);
DevConfig.save({
'tracker.url': data.url,
'tracker.username': data.username,
});
return data;
}
/**
* Build URL to perform requests to Jira.
*
* @param uri URI to add the the Jira URL.
* @return URL.
*/
buildRequestUrl(uri) {
return Utils.concatenatePaths([this.url, this.uri, '/rest/api/', apiVersion, uri]);
}
/**
* Delete an attachment.
*
* @param attachmentId Attachment ID.
* @return Promise resolved when done.
*/
async deleteAttachment(attachmentId) {
const response = await this.request(`attachment/${attachmentId}`, 'DELETE');
if (response.status != 204) {
throw new Error('Could not delete the attachment');
}
}
/**
* Load the issue info from jira server using a REST API call.
*
* @param key Key to identify the issue. E.g. MOBILE-1234.
* @param fields Fields to get.
* @return Promise resolved with the issue data.
*/
async getIssue(key, fields) {
fields = fields || '*all,-comment';
await this.init(); // Initialize data if needed.
const response = await this.request(`issue/${key}`, 'GET', {'fields': fields, 'expand': 'names'});
if (response.status == 404) {
throw new Error('Issue could not be found.');
} else if (response.status != 200) {
throw new Error('The tracker is not available.')
}
const issue = response.data;
issue.named = {};
// Populate the named fields in a separate key. Allows us to easily find them without knowing the field ID.
const nameList = issue.names || {};
for (const fieldKey in issue.fields) {
if (nameList[fieldKey]) {
issue.named[nameList[fieldKey]] = issue.fields[fieldKey];
}
}
return issue
}
/**
* Load the version info from the jira server using a rest api call.
*
* @return Promise resolved when done.
*/
async getServerInfo() {
const response = await this.request('serverInfo');
if (response.status != 200) {
throw new Error(`Unexpected response code: ${response.status}`, response);
}
this.version = response.data;
}
/**
* Get tracker data to push an issue.
*
* @return Promise resolved with the data.
*/
async getTrackerData() {
// Check dev-config file first.
let data = this.getTrackerDataFromDevConfig();
if (data) {
console.log('Using tracker data from dev-config file');
return data;
}
// Try to use mdk now.
try {
data = await this.getTrackerDataFromMdk();
console.log('Using tracker data from mdk');
return data;
} catch (error) {
// MDK not available or not configured. Ask for the data.
const trackerData = await this.askTrackerData();
trackerData.fromInput = true;
return trackerData;
}
}
/**
* Get tracker data from dev config file.
*
* @return Data, undefined if cannot get.
*/
getTrackerDataFromDevConfig() {
const url = DevConfig.get('tracker.url');
const username = DevConfig.get('tracker.username');
if (url && username) {
return {
url,
username,
};
}
}
/**
* Get tracker URL and username from mdk.
*
* @return Promise resolved with the data, rejected if cannot get.
*/
getTrackerDataFromMdk() {
return new Promise((resolve, reject) => {
exec('mdk config show tracker.url', (err, url) => {
if (!url) {
reject(err || 'URL not found.');
return;
}
exec('mdk config show tracker.username', (error, username) => {
if (username) {
resolve({
url: url.replace('\n', ''),
username: username.replace('\n', ''),
});
} else {
reject(error || 'Username not found.');
}
});
});
});
}
/**
* Initialize some data.
*
* @return Promise resolved when done.
*/
async init() {
if (this.initialized) {
// Already initialized.
return;
}
// Get tracker URL and username.
let trackerData = await this.getTrackerData();
this.url = trackerData.url;
this.username = trackerData.username;
const parsed = Url.parse(this.url);
this.ssl = parsed.protocol == 'https';
this.host = parsed.domain;
this.uri = parsed.path;
// Get the password.
const keytar = require('keytar');
this.password = await keytar.getPassword('mdk-jira-password', this.username); // Use same service name as mdk.
if (!this.password) {
// Ask the user.
this.password = await this.askPassword();
}
while (!this.initialized) {
try {
await this.getServerInfo();
this.initialized = true;
keytar.setPassword('mdk-jira-password', this.username, this.password);
} catch (error) {
console.log('Error connecting to the server. Please make sure you entered the data correctly.', error);
if (trackerData.fromInput) {
// User entered the data manually, ask him again.
trackerData = await this.askTrackerData();
this.url = trackerData.url;
this.username = trackerData.username;
}
this.password = await this.askPassword();
}
}
}
/**
* Check if a certain issue could be a security issue.
*
* @param key Key to identify the issue. E.g. MOBILE-1234.
* @return Promise resolved with boolean: whether it's a security issue.
*/
async isSecurityIssue(key) {
const issue = await this.getIssue(key, 'security');
return issue.fields && !!issue.fields.security;
}
/**
* Sends a request to the server and returns the data.
*
* @param uri URI to add the the Jira URL.
* @param method Method to use. Defaults to 'GET'.
* @param params Params to send as GET params (in the URL).
* @param data JSON string with the data to send as POST/PUT params.
* @param headers Headers to send.
* @return Promise resolved with the result.
*/
request(uri, method, params, data, headers) {
uri = uri || '';
method = (method || 'GET').toUpperCase();
data = data || '';
params = params || {};
headers = headers || {};
headers['Content-Type'] = 'application/json';
return new Promise((resolve, reject) => {
// Build the request URL.
const url = Url.addParamsToUrl(this.buildRequestUrl(uri), params);
// Initialize the request.
const options = {
method: method,
auth: `${this.username}:${this.password}`,
headers: headers,
};
const buildRequest = https.request(url, options);
// Add data.
if (data) {
buildRequest.write(data);
}
// Treat response.
buildRequest.on('response', (response) => {
// Read the result.
let result = '';
response.on('data', (chunk) => {
result += chunk;
});
response.on('end', () => {
try {
result = JSON.parse(result);
} catch (error) {
// Leave it as text.
}
resolve({
status: response.statusCode,
data: result,
});
});
});
buildRequest.on('error', (e) => {
reject(e);
});
// Send the request.
buildRequest.end();
});
}
/**
* Sets a set of fields for a certain issue in Jira.
*
* @param issueId Key to identify the issue. E.g. MOBILE-1234.
* @param updates Object with the fields to update.
* @return Promise resolved when done.
*/
async setCustomFields(issueId, updates) {
const issue = await this.getIssue(issueId);
const update = {'fields': {}};
// Detect which fields have changed.
for (const updateName in updates) {
const updateValue = updates[updateName];
const remoteValue = issue.named[updateName];
if (!remoteValue || remoteValue != updateValue) {
// Map the label of the field with the field code.
let fieldKey;
for (const id in issue.names) {
if (issue.names[id] == updateName) {
fieldKey = id;
break;
}
}
if (!fieldKey) {
throw new Error(`Could not find the field named ${updateName}.`);
}
update.fields[fieldKey] = updateValue;
}
}
if (!Object.keys(update.fields).length) {
// No fields to update.
console.log('No updates required.')
return;
}
const response = await this.request(`issue/${key}`, 'PUT', null, JSON.stringify(update));
if (response.status != 204) {
throw new Error(`Issue was not updated: ${response.status}`, response.data);
}
console.log('Issue updated successfully.');
}
/**
* Upload a new attachment to an issue.
*
* @param key Key to identify the issue. E.g. MOBILE-1234.
* @param filePath Path to the file to upload.
* @return Promise resolved when done.
*/
async upload(key, filePath) {
const uri = `issue/${key}/attachments`;
const headers = {
'X-Atlassian-Token': 'nocheck',
}
const response = await this.uploadFile(uri, 'file', filePath, headers);
if (response.status != 200) {
throw new Error('Could not upload file to Jira issue');
}
console.log('File successfully uploaded.')
}
/**
* Upload a file to Jira.
*
* @param uri URI to add the the Jira URL.
* @param fieldName Name of the form field where to put the file.
* @param filePath Path to the file.
* @param headers Headers.
* @return Promise resolved with the result.
*/
async uploadFile(uri, fieldName, filePath, headers) {
uri = uri || '';
headers = headers || {};
headers['Content-Type'] = 'multipart/form-data';
return new Promise((resolve) => {
// Add the file to the form data.
const formData = {};
formData[fieldName] = {
value: fs.createReadStream(filePath),
options: {
filename: filePath.substr(filePath.lastIndexOf('/') + 1),
contentType: 'multipart/form-data',
},
};
// Perform the request.
const options = {
url: this.buildRequestUrl(uri),
method: 'POST',
headers: headers,
auth: {
user: this.username,
pass: this.password,
},
formData: formData,
};
request(options, (_err, httpResponse, body) => {
resolve({
status: httpResponse.statusCode,
data: body,
});
});
});
}
}
module.exports = new Jira();

View File

@ -0,0 +1,118 @@
// (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.
const { writeFile, readdirSync, statSync, readFileSync } = require('fs');
const FONTS_PATH = 'src/assets/fonts';
const ICONS_JSON_FILE_PATH = 'src/assets/fonts/icons.json';
/**
* Get object with the map of icons for all fonts.
*
* @returns Icons map.
*/
function getIconsMap() {
const config = JSON.parse(readFileSync('moodle.config.json'));
let icons = {};
const fonts = readdirSync(FONTS_PATH);
fonts.forEach(font => {
const fontPath = `${FONTS_PATH}/${font}`;
if (statSync(fontPath).isFile()) {
// Not a font, ignore.
return;
}
icons = {
...icons,
...getFontIconsMap(config.iconsPrefixes, font, fontPath),
};
});
return icons;
}
/**
* Get object with the map of icons for a certain font.
*
* @param prefixes Prefixes to add to the icons.
* @param fontName Font name.
* @param fontPath Font path.
* @returns Icons map.
*/
function getFontIconsMap(prefixes, fontName, fontPath) {
const icons = {};
const fontLibraries = readdirSync(fontPath);
fontLibraries.forEach(libraryName => {
const libraryPath = `${fontPath}/${libraryName}`;
if (statSync(libraryPath).isFile()) {
// Not a font library, ignore.
return;
}
const libraryPrefixes = prefixes?.[fontName]?.[libraryName];
if (!libraryPrefixes || !libraryPrefixes.length) {
console.warn(`WARNING: There is no prefix for the library ${fontName}/${libraryName}. Adding icons without prefix is ` +
'discouraged, please add a prefix for your library in moodle.config.json file, in the iconsPrefixes property.');
}
const libraryIcons = readdirSync(libraryPath);
libraryIcons.forEach(iconFileName => {
if (!iconFileName.endsWith('.svg')) {
// Only accept svg files.
return;
}
if (iconFileName.includes('_')) {
throw Error(`Icon ${libraryPath}/${iconFileName} has an invalid name, it cannot contain '_'. `
+ 'Please rename it to use \'-\' instead.');
}
const iconName = iconFileName.replace('.svg', '');
const iconPath = `${libraryPath}/${iconFileName}`.replace('src/', '');
if (!libraryPrefixes || !libraryPrefixes.length) {
icons[iconName] = iconPath;
return;
}
libraryPrefixes.forEach(prefix => {
icons[`${prefix}-${iconName}`] = iconPath;
});
});
});
return icons;
}
/**
* Task to build a JSON file with the list of icons to add to Ionicons.
*/
class BuildIconsJsonTask {
/**
* Run the task.
*
* @param done Function to call when done.
*/
run(done) {
const icons = getIconsMap();
writeFile(ICONS_JSON_FILE_PATH, JSON.stringify(icons), done);
}
}
module.exports = BuildIconsJsonTask;

View File

@ -1,280 +0,0 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
const gulp = require('gulp');
const inquirer = require('inquirer');
const DevConfig = require('./dev-config');
const Git = require('./git');
const Jira = require('./jira');
const Utils = require('./utils');
/**
* Task to push a git branch and update tracker issue.
*/
class PushTask {
/**
* Ask the user whether he wants to continue.
*
* @return Promise resolved with boolean: true if he wants to continue.
*/
async askConfirmContinue() {
const answer = await inquirer.prompt([
{
type: 'input',
name: 'confirm',
message: 'Are you sure you want to continue?',
default: 'n',
},
]);
return answer.confirm == 'y';
}
/**
* Push a patch to the tracker and remove the previous one.
*
* @param branch Branch name.
* @param branchData Parsed branch data.
* @param remote Remote used.
* @return Promise resolved when done.
*/
async pushPatch(branch, branchData, remote) {
const headCommit = await Git.getHeadCommit(branch, branchData);
if (!headCommit) {
throw new Error('Head commit not resolved, abort pushing patch.');
}
// Create the patch file.
const fileName = branch + '.patch';
const tmpPatchPath = `./tmp/${fileName}`;
await Git.createPatch(`${headCommit}...${branch}`, tmpPatchPath);
console.log('Git patch created');
// Check if there is an attachment with same name in the issue.
const issue = await Jira.getIssue(branchData.issue, 'attachment');
let existingAttachmentId;
const attachments = (issue.fields && issue.fields.attachment) || [];
for (const i in attachments) {
if (attachments[i].filename == fileName) {
// Found an existing attachment with the same name, we keep track of it.
existingAttachmentId = attachments[i].id;
break
}
}
// Push the patch to the tracker.
console.log(`Uploading patch ${fileName} to the tracker...`);
await Jira.upload(branchData.issue, tmpPatchPath);
if (existingAttachmentId) {
// On success, deleting file that was there before.
try {
console.log('Deleting older patch...')
await Jira.deleteAttachment(existingAttachmentId);
} catch (error) {
console.log('Could not delete older attachment.');
}
}
}
/**
* Run the task.
*
* @param args Command line arguments.
* @param done Function to call when done.
*/
async run(args, done) {
try {
const remote = args.remote || DevConfig.get('upstreamRemote', 'origin');
let branch = args.branch;
const force = !!args.force;
if (!branch) {
branch = await Git.getCurrentBranch();
}
if (!branch) {
throw new Error('Cannot determine the current branch. Please make sure youu aren\'t in detached HEAD state');
} else if (branch == 'HEAD') {
throw new Error('Cannot push HEAD branch');
}
// Parse the branch to get the project and issue number.
const branchData = Utils.parseBranch(branch);
const keepRunning = await this.validateCommitMessages(branchData);
if (!keepRunning) {
// Last commit not valid, stop.
console.log('Exiting...');
done();
return;
}
if (!args.patch) {
// Check if it's a security issue to force patch mode.
try {
args.patch = await Jira.isSecurityIssue(branchData.issue);
if (args.patch) {
console.log(`${branchData.issue} appears to be a security issue, switching to patch mode...`);
}
} catch (error) {
console.log(`Could not check if ${branchData.issue} is a security issue.`);
}
}
if (args.patch) {
// Create and upload a patch file.
await this.pushPatch(branch, branchData, remote);
} else {
// Push the branch.
console.log(`Pushing branch ${branch} to remote ${remote}...`);
await Git.push(remote, branch, force);
// Update tracker info.
console.log(`Branch pushed, update tracker info...`);
await this.updateTrackerGitInfo(branch, branchData, remote);
}
} catch (error) {
console.error(error);
}
done();
}
/**
* Update git info in the tracker issue.
*
* @param branch Branch name.
* @param branchData Parsed branch data.
* @param remote Remote used.
* @return Promise resolved when done.
*/
async updateTrackerGitInfo(branch, branchData, remote) {
// Get the repository data for the project.
let repositoryUrl = DevConfig.get(branchData.project + '.repositoryUrl');
let diffUrlTemplate = DevConfig.get(branchData.project + '.diffUrlTemplate', '');
if (!repositoryUrl) {
// Calculate the repositoryUrl based on the remote URL.
repositoryUrl = await Git.getRemoteUrl(remote);
}
// Make sure the repository URL uses the regular format.
repositoryUrl = repositoryUrl.replace(/^(git@|git:\/\/)/, 'https://')
.replace(/\.git$/, '')
.replace('github.com:', 'github.com/');
if (!diffUrlTemplate) {
diffUrlTemplate = Utils.concatenatePaths([repositoryUrl, 'compare/%headcommit%...%branch%']);
}
// Now create the git URL for the repository.
const repositoryGitUrl = repositoryUrl.replace(/^https?:\/\//, 'git://') + '.git';
// Search HEAD commit to put in the diff URL.
console.log ('Searching for head commit...');
let headCommit = await Git.getHeadCommit(branch, branchData);
if (!headCommit) {
throw new Error('Head commit not resolved, aborting update of tracker fields');
}
headCommit = headCommit.substr(0, 10);
console.log(`Head commit resolved to ${headCommit}`);
// Calculate last properties needed.
const diffUrl = diffUrlTemplate.replace('%branch%', branch).replace('%headcommit%', headCommit);
const fieldRepositoryUrl = DevConfig.get('tracker.fieldnames.repositoryurl', 'Pull from Repository');
const fieldBranch = DevConfig.get('tracker.fieldnames.branch', 'Pull Master Branch');
const fieldDiffUrl = DevConfig.get('tracker.fieldnames.diffurl', 'Pull Master Diff URL');
// Update tracker fields.
const updates = {};
updates[fieldRepositoryUrl] = repositoryGitUrl;
updates[fieldBranch] = branch;
updates[fieldDiffUrl] = diffUrl;
console.log('Setting tracker fields...');
await Jira.setCustomFields(branchData.issue, updates);
}
/**
* Validate commit messages comparing them with the branch name.
*
* @param branchData Parsed branch data.
* @return True if value is ok or the user wants to continue anyway, false to stop.
*/
async validateCommitMessages(branchData) {
const messages = await Git.messages(30);
let numConsecutive = 0;
let wrongCommitCandidate = null;
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
const issue = Utils.getIssueFromCommitMessage(message);
if (!issue || issue != branchData.issue) {
if (i === 0) {
// Last commit is wrong, it shouldn't happen. Ask the user if he wants to continue.
if (!issue) {
console.log('The issue number could not be found in the last commit message.');
console.log(`Commit: ${message}`);
} else if (issue != branchData.issue) {
console.log('The issue number in the last commit does not match the branch being pushed to.');
console.log(`Branch: ${branchData.issue} vs. commit: ${issue}`);
}
return this.askConfirmContinue();
}
numConsecutive++;
if (numConsecutive > 2) {
// 3 consecutive commits with different branch, probably the branch commits are over. Everything OK.
return true;
// Don't treat a merge pull request commit as a wrong commit between right commits.
// The current push could be a quick fix after a merge.
} else if (!wrongCommitCandidate && message.indexOf('Merge pull request') == -1) {
wrongCommitCandidate = {
message: message,
issue: issue,
index: i,
};
}
} else if (wrongCommitCandidate) {
// We've found a commit with the branch name after a commit with a different branch. Probably wrong commit.
if (!wrongCommitCandidate.issue) {
console.log('The issue number could not be found in one of the commit messages.');
console.log(`Commit: ${wrongCommitCandidate.message}`);
} else {
console.log('The issue number in a certain commit does not match the branch being pushed to.');
console.log(`Branch: ${branchData.issue} vs. commit: ${wrongCommitCandidate.issue}`);
console.log(`Commit message: ${wrongCommitCandidate.message}`);
}
return this.askConfirmContinue();
}
}
return true;
}
}
module.exports = PushTask;

View File

@ -1,79 +0,0 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* Class with helper functions for urls.
*/
class Url {
/**
* Add params to a URL.
*
* @param url URL to add the params to.
* @param params Object with the params to add.
* @return URL with params.
*/
static addParamsToUrl(url, params) {
let separator = url.indexOf('?') != -1 ? '&' : '?';
for (const key in params) {
let value = params[key];
// Ignore objects.
if (typeof value != 'object') {
url += separator + key + '=' + value;
separator = '&';
}
}
return url;
}
/**
* Parse parts of a url, using an implicit protocol if it is missing from the url.
*
* @param url Url.
* @return Url parts.
*/
static parse(url) {
// Parse url with regular expression taken from RFC 3986: https://tools.ietf.org/html/rfc3986#appendix-B.
const match = url.trim().match(/^(([^:/?#]+):)?(\/\/([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?/);
if (!match) {
return null;
}
const host = match[4] || '';
// Get the credentials and the port from the host.
const [domainAndPort, credentials] = host.split('@').reverse();
const [domain, port] = domainAndPort.split(':');
const [username, password] = credentials ? credentials.split(':') : [];
// Prepare parts replacing empty strings with undefined.
return {
protocol: match[2] || undefined,
domain: domain || undefined,
port: port || undefined,
credentials: credentials || undefined,
username: username || undefined,
password: password || undefined,
path: match[5] || undefined,
query: match[7] || undefined,
fragment: match[9] || undefined,
};
}
}
module.exports = Url;

View File

@ -1,120 +0,0 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
const DevConfig = require('./dev-config');
const DEFAULT_ISSUE_REGEX = '^(MOBILE)[-_]([0-9]+)';
/**
* Class with some utility functions.
*/
class Utils {
/**
* Concatenate several paths, adding a slash between them if needed.
*
* @param paths List of paths.
* @return Concatenated path.
*/
static concatenatePaths(paths) {
if (!paths.length) {
return '';
}
// Remove all slashes between paths.
for (let i = 0; i < paths.length; i++) {
if (!paths[i]) {
continue;
}
if (i === 0) {
paths[i] = String(paths[i]).replace(/\/+$/g, '');
} else if (i === paths.length - 1) {
paths[i] = String(paths[i]).replace(/^\/+/g, '');
} else {
paths[i] = String(paths[i]).replace(/^\/+|\/+$/g, '');
}
}
// Remove empty paths.
paths = paths.filter(path => !!path);
return paths.join('/');
}
/**
* Get command line arguments.
*
* @return Object with command line arguments.
*/
static getCommandLineArguments() {
let args = {};
let curOpt;
for (const argument of process.argv) {
const thisOpt = argument.trim();
const option = thisOpt.replace(/^\-+/, '');
if (option === thisOpt) {
// argument value
if (curOpt) {
args[curOpt] = option;
}
curOpt = null;
}
else {
// Argument name.
curOpt = option;
args[curOpt] = true;
}
}
return args;
}
/**
* Given a commit message, return the issue name (e.g. MOBILE-1234).
*
* @param commit Commit message.
* @return Issue name.
*/
static getIssueFromCommitMessage(commit) {
const regex = new RegExp(DevConfig.get('wording.branchRegex', DEFAULT_ISSUE_REGEX), 'i');
const matches = commit.match(regex);
return matches && matches[0];
}
/**
* Parse a branch name to extract some data.
*
* @param branch Branch name to parse.
* @return Data.
*/
static parseBranch(branch) {
const regex = new RegExp(DevConfig.get('wording.branchRegex', DEFAULT_ISSUE_REGEX), 'i');
const matches = branch.match(regex);
if (!matches || matches.length < 3) {
throw new Error(`Error parsing branch ${branch}`);
}
return {
issue: matches[0],
project: matches[1],
issueNumber: matches[2],
};
}
}
module.exports = Utils;

View File

@ -15,9 +15,8 @@
const BuildLangTask = require('./gulp/task-build-lang');
const BuildBehatPluginTask = require('./gulp/task-build-behat-plugin');
const BuildEnvTask = require('./gulp/task-build-env');
const PushTask = require('./gulp/task-push');
const BuildIconsJsonTask = require('./gulp/task-build-icons-json');
const OverrideLangTask = require('./gulp/task-override-lang');
const Utils = require('./gulp/utils');
const gulp = require('gulp');
const paths = {
@ -30,8 +29,6 @@ const paths = {
],
};
const args = Utils.getCommandLineArguments();
// Build the language files into a single file per language.
gulp.task('lang', (done) => {
new BuildLangTask().run(paths.lang, done);
@ -47,6 +44,10 @@ gulp.task('env', (done) => {
new BuildEnvTask().run(done);
});
gulp.task('icons', (done) => {
new BuildIconsJsonTask().run(done);
});
// Build a Moodle plugin to run Behat tests.
if (BuildBehatPluginTask.isBehatConfigured()) {
gulp.task('behat', (done) => {
@ -54,15 +55,12 @@ if (BuildBehatPluginTask.isBehatConfigured()) {
});
}
gulp.task('push', (done) => {
new PushTask().run(args, done);
});
gulp.task(
'default',
gulp.parallel([
'lang',
'env',
'icons',
...(BuildBehatPluginTask.isBehatConfigured() ? ['behat'] : [])
]),
);

View File

@ -1,4 +1,4 @@
const { pathsToModuleNameMapper } = require('ts-jest/utils');
const { pathsToModuleNameMapper } = require('ts-jest');
const { compilerOptions } = require('./tsconfig');
module.exports = {
@ -9,17 +9,6 @@ module.exports = {
'src/**/*.{ts,html}',
'!src/testing/**/*',
],
transform: {
'^.+\\.(ts|html)$': 'ts-jest',
},
transformIgnorePatterns: ['node_modules/(?!@ionic-native|@ionic|@moodlehq/ionic-native-push)'],
moduleNameMapper: {
...pathsToModuleNameMapper(compilerOptions.paths, { prefix: '<rootDir>/src/' }),
'^!raw-loader!.*': 'jest-raw-loader',
},
globals: {
'ts-jest': {
tsconfig: './tsconfig.test.json',
},
},
transformIgnorePatterns: ['node_modules/(?!@stencil|@angular|@ionic|@moodlehq|@ngx-translate|swiper)'],
moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix: '<rootDir>/src/' }),
};

View File

@ -214,10 +214,7 @@ class behat_app extends behat_app_helper {
return true;
});
$this->wait_for_pending_js();
// Wait scroll animation to finish.
$this->getSession()->wait(300);
$this->wait_animations_done();
}
/**
@ -263,10 +260,7 @@ class behat_app extends behat_app_helper {
throw new DriverException('Error when swiping - ' . $result);
}
$this->wait_for_pending_js();
// Wait swipe animation to finish.
$this->getSession()->wait(300);
$this->wait_animations_done();
}
/**
@ -689,10 +683,7 @@ class behat_app extends behat_app_helper {
return true;
});
$this->wait_for_pending_js();
// Wait for UI to settle after refreshing.
$this->getSession()->wait(300);
$this->wait_animations_done();
if (is_null($locator)) {
return;
@ -790,13 +781,10 @@ class behat_app extends behat_app_helper {
/**
* Sets a field to the given text value in the app.
*
* Currently this only works for input fields which must be identified using a partial or
* exact match on the placeholder text.
*
* @Given /^I set the field "((?:[^"]|\\")+)" to "((?:[^"]|\\")*)" in the app$/
* @param string $field Text identifying field
* @param string $value Value for field
* @throws DriverException If the field set doesn't work
* @param string $field Text identifying the field.
* @param string $value Value to set. In select fields, this can be either the value or text included in the select option.
* @throws DriverException If the field set doesn't work.
*/
public function i_set_the_field_in_the_app(string $field, string $value) {
$field = addslashes_js($field);

View File

@ -641,4 +641,15 @@ EOF;
return $text;
}
}
/**
* Wait until animations have finished.
*/
protected function wait_animations_done() {
$this->wait_for_pending_js();
// Ideally, we wouldn't wait a fixed amount of time. But it is not straightforward to wait for animations
// to finish, so for now we'll just wait 300ms.
usleep(300000);
}
}

View File

@ -111,5 +111,16 @@
"long": 3500,
"sticky": 0
},
"disableTokenFile": false
"disableTokenFile": false,
"iconsPrefixes": {
"font-awesome": {
"brands": ["fab"],
"regular": ["far"],
"solid": ["fa", "fas"]
},
"moodle": {
"font-awesome": ["fam"],
"moodle": ["moodle"]
}
}
}

48218
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -45,39 +45,39 @@
"lang:create-langindex": "./scripts/create_langindex.sh"
},
"dependencies": {
"@angular/animations": "~10.0.14",
"@angular/common": "~10.0.14",
"@angular/core": "~10.0.14",
"@angular/forms": "~10.0.14",
"@angular/platform-browser": "~10.0.14",
"@angular/platform-browser-dynamic": "~10.0.14",
"@angular/router": "~10.0.14",
"@ionic-native/badge": "^5.36.0",
"@ionic-native/camera": "^5.36.0",
"@ionic-native/chooser": "^5.36.0",
"@ionic-native/clipboard": "^5.36.0",
"@ionic-native/core": "^5.36.0",
"@ionic-native/device": "^5.36.0",
"@ionic-native/diagnostic": "^5.36.0",
"@ionic-native/file": "^5.36.0",
"@ionic-native/file-opener": "^5.36.0",
"@ionic-native/file-transfer": "^5.36.0",
"@ionic-native/geolocation": "^5.36.0",
"@ionic-native/http": "^5.36.0",
"@ionic-native/in-app-browser": "^5.36.0",
"@ionic-native/ionic-webview": "^5.36.0",
"@ionic-native/keyboard": "^5.36.0",
"@ionic-native/local-notifications": "^5.36.0",
"@ionic-native/media-capture": "^5.36.0",
"@ionic-native/network": "^5.36.0",
"@ionic-native/qr-scanner": "^5.36.0",
"@ionic-native/splash-screen": "^5.36.0",
"@ionic-native/sqlite": "^5.36.0",
"@ionic-native/status-bar": "^5.36.0",
"@ionic-native/web-intent": "^5.36.0",
"@ionic-native/zip": "^5.36.0",
"@ionic/angular": "^5.9.4",
"@moodlehq/cordova-plugin-advanced-http": "^3.3.1-moodle.1",
"@angular/animations": "^16.2.0",
"@angular/common": "^16.2.0",
"@angular/compiler": "^16.2.0",
"@angular/core": "^16.2.0",
"@angular/forms": "^16.2.0",
"@angular/platform-browser": "^16.2.0",
"@angular/platform-browser-dynamic": "^16.2.0",
"@angular/router": "^16.2.0",
"@awesome-cordova-plugins/badge": "^6.3.0",
"@awesome-cordova-plugins/camera": "^6.3.0",
"@awesome-cordova-plugins/clipboard": "^6.3.0",
"@awesome-cordova-plugins/core": "^6.3.0",
"@awesome-cordova-plugins/device": "^6.3.0",
"@awesome-cordova-plugins/diagnostic": "^6.3.0",
"@awesome-cordova-plugins/file": "^6.3.0",
"@awesome-cordova-plugins/file-opener": "^6.3.0",
"@awesome-cordova-plugins/file-transfer": "^6.3.0",
"@awesome-cordova-plugins/geolocation": "^6.3.0",
"@awesome-cordova-plugins/http": "^6.3.0",
"@awesome-cordova-plugins/in-app-browser": "^6.3.0",
"@awesome-cordova-plugins/ionic-webview": "^6.3.0",
"@awesome-cordova-plugins/keyboard": "^6.3.0",
"@awesome-cordova-plugins/local-notifications": "^6.3.0",
"@awesome-cordova-plugins/media-capture": "^6.3.0",
"@awesome-cordova-plugins/network": "^6.3.0",
"@awesome-cordova-plugins/push": "^6.3.0",
"@awesome-cordova-plugins/splash-screen": "^6.3.0",
"@awesome-cordova-plugins/sqlite": "^6.3.0",
"@awesome-cordova-plugins/status-bar": "^6.3.0",
"@awesome-cordova-plugins/web-intent": "^6.3.0",
"@ionic/angular": "^7.6.1",
"@ionic/cordova-builders": "^10.0.0",
"@moodlehq/cordova-plugin-advanced-http": "3.3.1-moodle.1",
"@moodlehq/cordova-plugin-camera": "6.0.0-moodle.2",
"@moodlehq/cordova-plugin-file-transfer": "1.7.1-moodle.5",
"@moodlehq/cordova-plugin-inappbrowser": "5.0.0-moodle.3",
@ -85,12 +85,11 @@
"@moodlehq/cordova-plugin-ionic-webview": "5.0.0-moodle.2",
"@moodlehq/cordova-plugin-local-notification": "0.9.0-moodle.11",
"@moodlehq/cordova-plugin-qrscanner": "3.0.1-moodle.5",
"@moodlehq/cordova-plugin-statusbar": "4.0.0-moodle.2",
"@moodlehq/cordova-plugin-statusbar": "4.0.0-moodle.3",
"@moodlehq/cordova-plugin-zip": "3.1.0-moodle.1",
"@moodlehq/ionic-native-push": "5.36.0-moodle.2",
"@moodlehq/phonegap-plugin-push": "4.0.0-moodle.7",
"@ngx-translate/core": "^13.0.0",
"@ngx-translate/http-loader": "^6.0.0",
"@ngx-translate/core": "^15.0.0",
"@ngx-translate/http-loader": "^8.0.0",
"@types/chart.js": "^2.9.31",
"@types/cordova": "0.0.34",
"@types/dom-mediacapture-record": "1.0.7",
@ -119,7 +118,7 @@
"cordova.plugins.diagnostic": "^7.1.1",
"core-js": "^3.9.1",
"es6-promise-plugin": "^4.2.2",
"hammerjs": "^2.0.8",
"ionicons": "^7.0.0",
"jszip": "^3.7.1",
"mathjax": "2.7.9",
"moment": "^2.29.4",
@ -127,52 +126,44 @@
"mp3-mediarecorder": "4.0.5",
"nl.kingsquare.cordova.background-audio": "^1.0.1",
"ogv": "^1.8.9",
"rxjs": "~6.5.5",
"rxjs": "~7.8.0",
"swiper": "^11.0.3",
"ts-md5": "^1.2.7",
"tslib": "^2.3.1",
"tslib": "^2.3.0",
"video.js": "^7.21.1",
"zone.js": "~0.10.3"
"zone.js": "~0.13.0"
},
"devDependencies": {
"@angular-builders/custom-webpack": "^10.0.1",
"@angular-devkit/architect": "^0.1202.7",
"@angular-devkit/build-angular": "~0.1000.8",
"@angular-eslint/builder": "^4.2.0",
"@angular-eslint/eslint-plugin": "^4.2.0",
"@angular-eslint/eslint-plugin-template": "^4.2.0",
"@angular-eslint/schematics": "^4.2.0",
"@angular-eslint/template-parser": "^4.2.0",
"@angular/cli": "~10.0.8",
"@angular/compiler": "~10.0.14",
"@angular/compiler-cli": "~10.0.14",
"@angular/language-service": "~10.0.14",
"@ionic/angular-toolkit": "^2.3.3",
"@ionic/cli": "^6.19.0",
"@storybook/addon-controls": "~6.1.21",
"@storybook/addon-viewport": "~6.1.21",
"@storybook/angular": "~6.1.21",
"@angular-builders/custom-webpack": "^16.0.1",
"@angular-devkit/build-angular": "^16.2.10",
"@angular-eslint/builder": "^16.2.0",
"@angular-eslint/eslint-plugin": "^16.2.0",
"@angular-eslint/eslint-plugin-template": "^16.2.0",
"@angular-eslint/schematics": "^16.2.0",
"@angular-eslint/template-parser": "^16.2.0",
"@angular/cli": "^16.2.10",
"@angular/compiler-cli": "^16.2.0",
"@angular/language-service": "^16.2.0",
"@ionic/angular-toolkit": "^10.0.0",
"@ionic/cli": "^7.1.5",
"@types/faker": "^5.1.3",
"@types/jest": "^26.0.24",
"@types/marked": "^4.3.1",
"@types/node": "^12.12.64",
"@types/node": "^18.0.0",
"@types/resize-observer-browser": "^0.1.5",
"@types/webpack-env": "^1.16.0",
"@typescript-eslint/eslint-plugin": "^4.22.0",
"@typescript-eslint/parser": "^4.22.0",
"check-es-compat": "^1.1.1",
"compare-versions": "^4.1.4",
"@types/webpack-env": "^1.18.4",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"check-es-compat": "^3.1.0",
"concurrently": "^8.2.0",
"cordova-plugin-moodleapp": "file:cordova-plugin-moodleapp",
"cross-env": "^7.0.3",
"eslint": "^7.25.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-deprecation": "^1.5.0",
"eslint": "^8.0.0",
"eslint-plugin-deprecation": "^2.0.0",
"eslint-plugin-header": "^3.1.1",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-jest": "^24.3.6",
"eslint-plugin-jsdoc": "^32.3.3",
"eslint-plugin-jest": "^27.6.0",
"eslint-plugin-jsdoc": "^46.9.0",
"eslint-plugin-prefer-arrow": "^1.2.3",
"eslint-plugin-promise": "^5.1.0",
"eslint-plugin-promise": "^6.1.1",
"faker": "^5.1.0",
"fs-extra": "^9.1.0",
"gulp": "4.0.2",
@ -182,24 +173,19 @@
"gulp-htmlmin": "^5.0.1",
"gulp-rename": "^2.0.0",
"gulp-slash": "^1.1.3",
"jest": "^26.5.2",
"jest-preset-angular": "^8.3.1",
"jest-raw-loader": "^1.0.1",
"jest": "^29.7.0",
"jest-preset-angular": "^13.1.4",
"jsonc-parser": "^2.3.1",
"marked": "^4.3.0",
"minimatch": "^5.1.0",
"native-run": "^1.4.0",
"minimatch": "^9.0.3",
"native-run": "^2.0.0",
"patch-package": "^6.5.0",
"storybook-addon-designs": "~6.1.0",
"storybook-addon-rtl-direction": "0.0.19",
"storybook-dark-mode": "^3.0.0",
"terser-webpack-plugin": "^4.2.3",
"ts-jest": "^26.4.1",
"ts-node": "~8.3.0",
"typescript": "^3.9.9"
"terser-webpack-plugin": "^5.3.9",
"ts-jest": "^29.1.1",
"ts-node": "^8.3.0",
"typescript": "~5.1.3"
},
"engines": {
"node": ">=14.15.0 <15"
"node": ">=18.18.2 <19"
},
"cordova": {
"platforms": [
@ -210,11 +196,26 @@
"@moodlehq/cordova-plugin-advanced-http": {
"ANDROIDBLACKLISTSECURESOCKETPROTOCOLS": "SSLv3,TLSv1"
},
"cordova-clipboard": {},
"cordova-plugin-badge": {},
"@moodlehq/cordova-plugin-camera": {
"ANDROIDX_CORE_VERSION": "1.6.+"
},
"@moodlehq/cordova-plugin-file-transfer": {},
"@moodlehq/cordova-plugin-inappbrowser": {},
"@moodlehq/cordova-plugin-intent": {},
"@moodlehq/cordova-plugin-ionic-webview": {},
"@moodlehq/cordova-plugin-local-notification": {
"ANDROID_SUPPORT_V4_VERSION": "26.+"
},
"@moodlehq/cordova-plugin-qrscanner": {},
"@moodlehq/cordova-plugin-statusbar": {},
"@moodlehq/cordova-plugin-zip": {},
"@moodlehq/phonegap-plugin-push": {
"ANDROIDX_CORE_VERSION": "1.6.+",
"FCM_VERSION": "23.+"
},
"cordova-clipboard": {},
"cordova-plugin-androidx-adapter": {},
"cordova-plugin-badge": {},
"cordova-plugin-chooser": {},
"cordova-plugin-customurlscheme": {
"URL_SCHEME": "moodlemobile",
@ -227,39 +228,21 @@
"cordova-plugin-geolocation": {
"GPS_REQUIRED": "false"
},
"@moodlehq/cordova-plugin-inappbrowser": {},
"cordova-plugin-ionic-keyboard": {},
"@moodlehq/cordova-plugin-ionic-webview": {},
"@moodlehq/cordova-plugin-local-notification": {
"ANDROID_SUPPORT_V4_VERSION": "26.+"
},
"cordova-plugin-media-capture": {},
"cordova-plugin-moodleapp": {},
"cordova-plugin-network-information": {},
"@moodlehq/cordova-plugin-qrscanner": {},
"@moodlehq/cordova-plugin-statusbar": {},
"cordova-plugin-prevent-override": {},
"cordova-plugin-screen-orientation": {},
"cordova-plugin-wkuserscript": {},
"cordova-plugin-wkwebview-cookies": {},
"@moodlehq/cordova-plugin-zip": {},
"cordova-sqlite-storage": {},
"@moodlehq/phonegap-plugin-push": {
"ANDROIDX_CORE_VERSION": "1.6.+",
"FCM_VERSION": "23.+"
},
"@moodlehq/cordova-plugin-intent": {},
"nl.kingsquare.cordova.background-audio": {},
"cordova.plugins.diagnostic": {
"ANDROID_SUPPORT_VERSION": "28.+",
"ANDROIDX_VERSION": "1.0.0",
"ANDROIDX_APPCOMPAT_VERSION": "1.3.1"
},
"@moodlehq/cordova-plugin-file-transfer": {},
"cordova-plugin-prevent-override": {},
"cordova-plugin-androidx-adapter": {},
"cordova-plugin-screen-orientation": {},
"cordova-plugin-moodleapp": {}
"nl.kingsquare.cordova.background-audio": {}
}
},
"optionalDependencies": {
"keytar": "^7.2.0"
}
}
}

View File

@ -1,13 +0,0 @@
diff --git a/node_modules/@ionic/core/dist/types/components.d.ts b/node_modules/@ionic/core/dist/types/components.d.ts
index fd9b7ad..4d29d1e 100644
--- a/node_modules/@ionic/core/dist/types/components.d.ts
+++ b/node_modules/@ionic/core/dist/types/components.d.ts
@@ -972,7 +972,7 @@ export namespace Components {
/**
* If `true`, a button tag will be rendered and the item will be tappable.
*/
- "button": boolean;
+ "button": boolean | '';
/**
* The color to use from your application's color palette. Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. For more information on colors, see [theming](/docs/theming/basics).
*/

View File

@ -0,0 +1,65 @@
diff --git a/node_modules/check-es-compat/bin/cli.mjs b/node_modules/check-es-compat/bin/cli.mjs
index 25c53f5..26ce475 100755
--- a/node_modules/check-es-compat/bin/cli.mjs
+++ b/node_modules/check-es-compat/bin/cli.mjs
@@ -17,7 +17,8 @@ if (args.length === 0) {
}
}
-async function execute(files) {
+async function execute(args) {
+ const { files, polyfills } = parseArguments(args);
const eslint = new ESLint({
// Ignore any config files
useEslintrc: false,
@@ -34,7 +35,7 @@ async function execute(files) {
es2021: true,
},
rules: {
- 'ecmascript-compat/compat': 'error',
+ 'ecmascript-compat/compat': ['error', { polyfills }],
},
},
});
@@ -46,3 +47,41 @@ async function execute(files) {
return { hasErrors: results.some((result) => result.errorCount > 0) };
}
+
+function parseArguments(args) {
+ const files = [];
+ const polyfills = [];
+ let nextArgIsPolyfills = false;
+
+ for (const arg of args) {
+ if (nextArgIsPolyfills) {
+ nextArgIsPolyfills = false;
+ polyfills.push(...splitPolyfillsArgument(arg));
+ continue;
+ }
+
+ if (arg.startsWith('--polyfills')) {
+ if (arg.startsWith('--polyfills=')) {
+ polyfills.push(...splitPolyfillsArgument(arg.slice(12)));
+ } else {
+ nextArgIsPolyfills = true;
+ }
+
+ continue;
+ }
+
+ files.push(arg);
+ }
+
+ return { files, polyfills };
+}
+
+function splitPolyfillsArgument(polyfills) {
+ const prototypeAtPolyfill = '{Array,String,TypedArray}.prototype.at';
+ const prototypeAtPlaceholder = '{{PROTOTYPEAT}}';
+
+ return polyfills
+ .replace(prototypeAtPolyfill, prototypeAtPlaceholder)
+ .split(',')
+ .map(polyfill => polyfill === prototypeAtPlaceholder ? prototypeAtPolyfill : polyfill);
+}

View File

@ -1,21 +0,0 @@
diff --git a/node_modules/eslint-plugin-ecmascript-compat/lib/compatibility.js b/node_modules/eslint-plugin-ecmascript-compat/lib/compatibility.js
index 57772cd..f3667fd 100644
--- a/node_modules/eslint-plugin-ecmascript-compat/lib/compatibility.js
+++ b/node_modules/eslint-plugin-ecmascript-compat/lib/compatibility.js
@@ -1,5 +1,7 @@
/* eslint-disable camelcase, no-underscore-dangle */
+const compareVersions = require('compare-versions').compare;
+
function forbiddenFeatures(features, targets) {
return features.filter(feature => !isFeatureSupportedByTargets(feature, targets));
}
@@ -30,7 +32,7 @@ function isCompatFeatureSupportedByTarget(compatFeature, target) {
return true;
}
- return !support.isNone && target.version >= versionAdded;
+ return !support.isNone && target.version.split('-').every(version => compareVersions(version, versionAdded, '>='));
}
function getSimpleSupportStatement(compatFeature, target) {

View File

@ -1,30 +0,0 @@
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

@ -14,7 +14,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
const minimatch = require('minimatch');
const { minimatch } = require('minimatch');
const { existsSync, readFileSync, writeFileSync, statSync, renameSync, rmSync } = require('fs');
const { readdir } = require('fs').promises;
const { mkdirSync, copySync } = require('fs-extra');

View File

@ -72,6 +72,7 @@
"addon.block_starredcourses.nocourses": "block_starredcourses",
"addon.block_starredcourses.pluginname": "block_starredcourses",
"addon.block_tags.pluginname": "block_tags",
"addon.block_timeline.ariadayfilter": "block_timeline",
"addon.block_timeline.duedate": "block_timeline",
"addon.block_timeline.next30days": "block_timeline",
"addon.block_timeline.next3months": "block_timeline",
@ -1792,6 +1793,7 @@
"core.fileuploader.uploadingperc": "local_moodlemobileapp",
"core.fileuploader.video": "local_moodlemobileapp",
"core.filter": "moodle",
"core.firstdayofweek": "langconfig",
"core.folder": "moodle",
"core.forcepasswordchangenotice": "moodle",
"core.fulllistofcourses": "moodle",

View File

@ -1,7 +1,7 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate"></ion-back-button>
<ion-back-button [text]="'core.back' | translate" />
</ion-buttons>
<ion-title>
<h1 *ngIf="badge">{{ badge.name }}</h1>
@ -11,7 +11,7 @@
</ion-header>
<ion-content [core-swipe-navigation]="badges" class="limited-width">
<ion-refresher slot="fixed" [disabled]="!badgeLoaded" (ionRefresh)="refreshBadges($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}" />
</ion-refresher>
<core-loading [hideUntil]="badgeLoaded">
<ion-item-group *ngIf="badge">
@ -122,8 +122,7 @@
<ion-label>
<p class="item-heading">{{ 'core.course' | translate}}</p>
<p>
<core-format-text [text]="course.fullname" contextLevel="course" [contextInstanceId]="courseId">
</core-format-text>
<core-format-text [text]="course.fullname" contextLevel="course" [contextInstanceId]="courseId" />
</p>
</ion-label>
</ion-item>
@ -217,7 +216,7 @@
<p class="item-heading">{{ relatedBadge.name }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.relatedbadges.length == 0">
<ion-item class="ion-text-wrap" *ngIf="badge.relatedbadges.length === 0">
<ion-label>
<p class="item-heading">{{ 'addon.badges.norelated' | translate}}</p>
</ion-label>
@ -237,7 +236,7 @@
<p class="item-heading">{{ alignment.targetname }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.alignment.length == 0">
<ion-item class="ion-text-wrap" *ngIf="badge.alignment.length === 0">
<ion-label>
<p class="item-heading">{{ 'addon.badges.noalignment' | translate}}</p>
</ion-label>

View File

@ -1,7 +1,7 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate"></ion-back-button>
<ion-back-button [text]="'core.back' | translate" />
</ion-buttons>
<ion-title>
<h1>{{ 'addon.badges.badges' | translate }}</h1>
@ -11,11 +11,10 @@
<ion-content>
<core-split-view>
<ion-refresher slot="fixed" [disabled]="!badges.loaded" (ionRefresh)="refreshBadges($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}" />
</ion-refresher>
<core-loading [hideUntil]="badges.loaded">
<core-empty-box *ngIf="badges.empty" icon="fas-trophy" [message]="'addon.badges.nobadges' | translate">
</core-empty-box>
<core-empty-box *ngIf="badges.empty" icon="fas-trophy" [message]="'addon.badges.nobadges' | translate" />
<ion-list *ngIf="!badges.empty" class="ion-no-margin">
<ion-item button class="ion-text-wrap" *ngFor="let badge of badges.items" [attr.aria-label]="badge.name"

View File

@ -5,8 +5,7 @@
</ion-item-divider>
<core-loading [hideUntil]="loaded">
<ion-item class="ion-text-wrap" *ngFor="let entry of entries" [detail]="true" button (click)="gotoCoureListModType(entry)">
<core-mod-icon slot="start" [modicon]="entry.icon" [modname]="entry.iconModName" [showAlt]="false">
</core-mod-icon>
<core-mod-icon slot="start" [modicon]="entry.icon" [modname]="entry.iconModName" [showAlt]="false" />
<ion-label>{{ entry.name }}</ion-label>
</ion-item>
</core-loading>

View File

@ -7,16 +7,14 @@
<div *ngIf="downloadCoursesEnabled && filteredCourses.length > 0" class="core-button-spinner">
<ion-button *ngIf="!prefetchCoursesData.loading" fill="clear" (click)="prefetchCourses()"
[attr.aria-label]="prefetchCoursesData.statusTranslatable | translate">
<ion-icon [name]="prefetchCoursesData.icon" slot="icon-only" aria-hidden="true">
</ion-icon>
<ion-icon [name]="prefetchCoursesData.icon" slot="icon-only" aria-hidden="true" />
</ion-button>
<ion-badge class="core-course-download-courses-progress" *ngIf="prefetchCoursesData.badge" role="progressbar"
[attr.aria-valuemax]="prefetchCoursesData.total" [attr.aria-valuenow]="prefetchCoursesData.count"
[attr.aria-valuetext]="prefetchCoursesData.badgeA11yText">
{{prefetchCoursesData.badge}}
</ion-badge>
<ion-spinner *ngIf="prefetchCoursesData.loading" [attr.aria-label]="'core.loading' | translate">
</ion-spinner>
<ion-spinner *ngIf="prefetchCoursesData.loading" [attr.aria-label]="'core.loading' | translate" />
</div>
</div>
</ion-item-divider>
@ -26,8 +24,7 @@
<ion-col>
<!-- Filter courses. -->
<ion-searchbar [(ngModel)]="textFilter" (ionInput)="filterTextChanged($event.target)"
(ionCancel)="filterTextChanged($event.target)" [placeholder]="'core.courses.filtermycourses' | translate">
</ion-searchbar>
(ionCancel)="filterTextChanged($event.target)" [placeholder]="'core.courses.filtermycourses' | translate" />
</ion-col>
</ion-row>
<ion-row class="ion-justify-content-between ion-align-items-center addon-block-myoverview-filter" *ngIf="hasCourses">
@ -70,12 +67,11 @@
<ion-col>
<!-- Filter courses. -->
<ion-searchbar class="ion-hide-md-down" [(ngModel)]="textFilter" (ionInput)="filterTextChanged($event.target)"
(ionCancel)="filterTextChanged($event.target)" [placeholder]="'core.courses.filtermycourses' | translate">
</ion-searchbar>
(ionCancel)="filterTextChanged($event.target)" [placeholder]="'core.courses.filtermycourses' | translate" />
</ion-col>
<ion-col size="auto" *ngIf="sort.enabled">
<core-combobox [label]="'core.sortby' | translate" [selection]="sort.selected" (onChange)="sortCourses($event)"
icon="fas-arrow-down-short-wide">
icon="fas-arrow-down-short-wide" class="no-border">
<ion-select-option class="ion-text-wrap" value="fullname">
{{'addon.block_myoverview.title' | translate}}
</ion-select-option>
@ -90,16 +86,16 @@
<ion-col size="auto" *ngIf="isLayoutSwitcherAvailable">
<ion-button *ngIf="layout === 'card'" fill="outline" (click)="toggleLayout('list')"
[attr.aria-label]="'addon.block_myoverview.aria:list' | translate">
<ion-icon slot="icon-only" name="fas-list" aria-hidden="true"></ion-icon>
<ion-icon slot="icon-only" name="fas-list" aria-hidden="true" />
</ion-button>
<ion-button *ngIf="layout === 'list'" fill="outline" (click)="toggleLayout('card')"
[attr.aria-label]="'addon.block_myoverview.aria:card' | translate">
<ion-icon slot="icon-only" name="fas-table-cells-large" aria-hidden="true"></ion-icon>
<ion-icon slot="icon-only" name="fas-table-cells-large" aria-hidden="true" />
</ion-button>
</ion-col>
</ion-row>
<core-empty-box *ngIf="filteredCourses.length == 0" image="assets/img/icons/courses.svg">
<core-empty-box *ngIf="filteredCourses.length === 0" image="assets/img/icons/courses.svg">
<p *ngIf="hasCourses" class="item-heading">
{{'addon.block_myoverview.noresult' | translate}}
</p>
@ -114,8 +110,7 @@
{{'addon.block_myoverview.nocoursesenrolleddescription' | translate}}
</p>
<ion-button (click)="openSearch()" fill="outline">
<ion-icon name="fas-magnifying-glass" slot="start" aria-hidden="true">
</ion-icon>
<ion-icon name="fas-magnifying-glass" slot="start" aria-hidden="true" />
{{'addon.block_myoverview.browseallcourses' | translate}}
</ion-button>
</ng-container>
@ -128,8 +123,7 @@
<ion-col *ngFor="let course of filteredCourses" class="ion-no-padding" size="12" size-sm="6" size-md="6" size-lg="4"
size-xl="3">
<core-courses-course-list-item [course]="course" class="core-courseoverview" [showDownload]="downloadCourseEnabled"
[layout]="layout">
</core-courses-course-list-item>
[layout]="layout" />
</ion-col>
</ion-row>
</ion-grid>

View File

@ -10,25 +10,20 @@
}
ion-button,
core-combobox ::ng-deep ion-button {
--border-width: 0;
core-combobox ::ng-deep ion-select {
--a11y-min-target-size: 40px;
margin: 0;
}
ion-button {
--border-width: 0;
.select-icon {
display: none;
}
ion-icon {
font-size: 20px;
}
}
core-combobox ::ng-deep ion-select {
margin: 0;
--a11y-min-target-size: 40px;
}
ion-searchbar {
ion-searchbar {
padding: 0;
--height: 40px;
}

View File

@ -3,21 +3,19 @@
<h2>{{ 'addon.block_recentlyaccessedcourses.pluginname' | translate }}</h2>
</ion-label>
<div slot="end" class="flex-row">
<core-horizontal-scroll-controls #scrollControls [aria-controls]="scrollElementId">
</core-horizontal-scroll-controls>
<core-horizontal-scroll-controls #scrollControls [aria-controls]="scrollElementId" />
</div>
</ion-item-divider>
<core-loading [hideUntil]="loaded">
<core-empty-box *ngIf="courses.length == 0" image="assets/img/icons/courses.svg"
[message]="'addon.block_recentlyaccessedcourses.nocourses' | translate"></core-empty-box>
<core-empty-box *ngIf="courses.length === 0" image="assets/img/icons/courses.svg"
[message]="'addon.block_recentlyaccessedcourses.nocourses' | translate" />
<!-- List of courses. -->
<div [hidden]="courses.length === 0" [id]="scrollElementId" class="core-horizontal-scroll"
(scroll)="scrollControls.updateScrollPosition()">
<div (onResize)="scrollControls.updateScrollPosition()" class="flex-row">
<div class="safe-area-pseudo-padding-start"></div>
<ng-container *ngFor="let course of courses">
<core-courses-course-list-item [course]="course" class="core-recentlyaccessedcourses" layout="summarycard">
</core-courses-course-list-item>
<core-courses-course-list-item [course]="course" class="core-recentlyaccessedcourses" layout="summarycard" />
</ng-container>
<div class="safe-area-pseudo-padding-end"></div>
</div>

View File

@ -3,8 +3,7 @@
<h2>{{ 'addon.block_recentlyaccesseditems.pluginname' | translate }}</h2>
</ion-label>
<div slot="end">
<core-horizontal-scroll-controls #scrollControls [aria-controls]="scrollElementId">
</core-horizontal-scroll-controls>
<core-horizontal-scroll-controls #scrollControls [aria-controls]="scrollElementId" />
</div>
</ion-item-divider>
<core-loading [hideUntil]="loaded">
@ -16,18 +15,16 @@
<ion-card>
<ion-item class="core-course-module-handler ion-text-wrap" [detail]="false" (click)="action($event, item)" button>
<core-mod-icon slot="start" *ngIf="item.iconUrl" [modicon]="item.iconUrl" [modname]="item.modname"
[componentId]="item.cmid" [showAlt]="false" [purpose]="item.purpose">
</core-mod-icon>
[componentId]="item.cmid" [showAlt]="false" [purpose]="item.purpose" />
<ion-label>
<!-- Add the icon title so accessibility tools read it. -->
<span class="sr-only" *ngIf="item.iconTitle">{{ item.iconTitle }}</span>
<p class="item-heading">
<core-format-text [text]="item.name" contextLevel="module" [contextInstanceId]="item.cmid"
[courseId]="item.courseid"></core-format-text>
[courseId]="item.courseid" />
</p>
<p>
<core-format-text [text]="item.coursename" contextLevel="course" [contextInstanceId]="item.courseid">
</core-format-text>
<core-format-text [text]="item.coursename" contextLevel="course" [contextInstanceId]="item.courseid" />
</p>
</ion-label>
</ion-item>
@ -38,6 +35,6 @@
</div>
<core-empty-box *ngIf="items.length <= 0" image="assets/img/icons/activities.svg"
[message]="'addon.block_recentlyaccesseditems.noitems' | translate"></core-empty-box>
[message]="'addon.block_recentlyaccesseditems.noitems' | translate" />
</core-loading>

View File

@ -8,10 +8,10 @@
<ion-item class="ion-text-wrap" *ngIf="mainMenuBlock.summary">
<ion-label>
<core-format-text [text]="mainMenuBlock.summary" [component]="component" [componentId]="siteHomeId" contextLevel="course"
[contextInstanceId]="siteHomeId"></core-format-text>
[contextInstanceId]="siteHomeId" />
</ion-label>
</ion-item>
<core-course-module *ngFor="let module of mainMenuBlock.modules" [module]="module" [section]="mainMenuBlock"></core-course-module>
<core-course-module *ngFor="let module of mainMenuBlock.modules" [module]="module" [section]="mainMenuBlock" />
</ion-list>
</core-loading>

View File

@ -3,21 +3,19 @@
<h2>{{ 'addon.block_starredcourses.pluginname' | translate }}</h2>
</ion-label>
<div slot="end" class="flex-row">
<core-horizontal-scroll-controls #scrollControls [aria-controls]="scrollElementId">
</core-horizontal-scroll-controls>
<core-horizontal-scroll-controls #scrollControls [aria-controls]="scrollElementId" />
</div>
</ion-item-divider>
<core-loading [hideUntil]="loaded">
<core-empty-box *ngIf="courses.length == 0" image="assets/img/icons/courses.svg"
[message]="'addon.block_starredcourses.nocourses' | translate"></core-empty-box>
<core-empty-box *ngIf="courses.length === 0" image="assets/img/icons/courses.svg"
[message]="'addon.block_starredcourses.nocourses' | translate" />
<!-- List of courses. -->
<div [hidden]="courses.length === 0" [id]="scrollElementId" class="core-horizontal-scroll"
(scroll)="scrollControls.updateScrollPosition()">
<div (onResize)="scrollControls.updateScrollPosition()" class="flex-row">
<div class="safe-area-pseudo-padding-start"></div>
<ng-container *ngFor="let course of courses">
<core-courses-course-list-item [course]="course" class="core-block_starredcourses" layout="summarycard">
</core-courses-course-list-item>
<core-courses-course-list-item [course]="course" class="core-block_starredcourses" layout="summarycard" />
</ng-container>
<div class="safe-area-pseudo-padding-end"></div>
</div>

View File

@ -1,14 +1,20 @@
:host .core-block-content ::ng-deep {
ion-label {
max-width: 100%;
}
.tag_cloud {
text-align: center;
ul.inline-list {
list-style: none;
margin: 0;
-webkit-padding-start: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
li {
padding: .2em;
display: inline-block;
a {
background: var(--primary);

View File

@ -2,8 +2,7 @@
<ion-label class="ion-text-wrap">
<h3>
<span class="sr-only">{{ 'core.courses.aria:coursename' | translate }}</span>
<core-format-text [text]="course.displayname || course.fullname" contextLevel="course" [contextInstanceId]="course.id">
</core-format-text>
<core-format-text [text]="course.displayname || course.fullname" contextLevel="course" [contextInstanceId]="course.id" />
</h3>
</ion-label>
</ion-item>
@ -20,18 +19,16 @@
<ion-row class="ion-justify-content-between ion-align-items-center ion-no-padding">
<ion-col class="addon-block-timeline-activity-main ion-no-padding">
<ion-row class="ion-justify-content-between ion-align-items-center ion-nowrap ion-no-padding">
<ion-col class="addon-block-timeline-activity-time ion-no-padding">
<ion-col class="addon-block-timeline-activity-time ion-no-padding ion-text-nowrap">
<small>{{event.timesort * 1000 | coreFormatDate:"strftimetime24" }}</small>
<core-mod-icon *ngIf="event.iconUrl" [modicon]="event.iconUrl" [componentId]="event.instance"
[modname]="event.modulename" [purpose]="event.purpose">
</core-mod-icon>
[modname]="event.modulename" [purpose]="event.purpose" />
</ion-col>
<ion-col class="addon-block-timeline-activity-name ion-no-padding">
<p class="item-heading">
<span>
<core-format-text [text]="event.activityname || event.name" contextLevel="module"
[contextInstanceId]="event.id" [courseId]="event.course?.id">
</core-format-text>
[contextInstanceId]="event.id" [courseId]="event.course?.id" />
</span>
<ion-badge *ngIf="event.overdue" color="danger">{{ 'addon.block_timeline.overdue' | translate }}
</ion-badge>
@ -39,15 +36,13 @@
<p *ngIf="showInlineCourse && event.course">
<span>
<core-format-text [text]="event.course.fullnamedisplay" contextLevel="course"
[contextInstanceId]="event.course.id">
</core-format-text>
[contextInstanceId]="event.course.id" />
</span>
</p>
<p *ngIf="event.activitystr">
<span>
<core-format-text *ngIf="event.activitystr" [text]="event.activitystr" contextLevel="module"
[contextInstanceId]="event.id">
</core-format-text>
[contextInstanceId]="event.id" />
</span>
</p>
</ion-col>
@ -72,5 +67,5 @@
<ion-button expand="block" (click)="loadMore.emit()" fill="outline" *ngIf="!loadingMore">
{{ 'core.loadmore' | translate }}
</ion-button>
<ion-spinner *ngIf="loadingMore" [attr.aria-label]="'core.loading' | translate"></ion-spinner>
<ion-spinner *ngIf="loadingMore" [attr.aria-label]="'core.loading' | translate" />
</div>

View File

@ -9,12 +9,13 @@
<!-- Filter courses. -->
<core-search-box (onSubmit)="searchChanged($event)" (onClear)="searchChanged('')"
[placeholder]="'addon.block_timeline.searchevents' | translate" autocorrect="off" spellcheck="false" [lengthCheck]="2"
searchArea="AddonBlockTimeline"></core-search-box>
searchArea="AddonBlockTimeline" />
</ion-col>
</ion-row>
<ion-row class="ion-justify-content-between ion-align-items-center addon-block-timeline-filter">
<ion-col size="auto">
<core-combobox [formControl]="filter" (onChange)="filterChanged($event)">
<core-combobox [formControl]="filter" (onChange)="filterChanged($event)"
[label]="'addon.block_timeline.ariadayfilter' | translate">
<ion-select-option *ngFor="let option of statusFilterOptions; last as last"
[attr.class]="'ion-text-wrap' + last ? ' core-select-option-border-bottom' : ''" [value]="option.value">
{{ option.name | translate }}
@ -31,11 +32,11 @@
<!-- Filter courses. -->
<core-search-box (onSubmit)="searchChanged($event)" (onClear)="searchChanged('')"
[placeholder]="'addon.block_timeline.searchevents' | translate" autocorrect="off" spellcheck="false" [lengthCheck]="2"
searchArea="AddonBlockTimeline"></core-search-box>
searchArea="AddonBlockTimeline" />
</ion-col>
<ion-col size="auto">
<core-combobox [label]="'core.sortby' | translate" [formControl]="sort" (onChange)="sortChanged($event)"
icon="fas-arrow-down-short-wide">
icon="fas-arrow-down-short-wide" class="no-border">
<ion-select-option *ngFor="let option of sortOptions" class="ion-text-wrap" [value]="option.value">
{{ option.name | translate }}
</ion-select-option>
@ -46,9 +47,9 @@
<ng-container *ngFor="let section of sections">
<addon-block-timeline-events *ngIf="section.data$ | async as data" [events]="data.events"
[showInlineCourse]="(sort$ | async) === AddonBlockTimelineSort.ByDates" [canLoadMore]="data.canLoadMore"
[loadingMore]="data.loadingMore" (loadMore)="section.loadMore()" [course]="section.course"> </addon-block-timeline-events>
[loadingMore]="data.loadingMore" (loadMore)="section.loadMore()" [course]="section.course" />
</ng-container>
<core-empty-box *ngIf="sections && sections.length === 0" image="assets/img/icons/courses.svg"
[message]="'addon.block_timeline.noevents' | translate"></core-empty-box>
[message]="'addon.block_timeline.noevents' | translate" />
</ng-container>
</core-loading>

View File

@ -39,10 +39,10 @@ import { CoreLogger } from '@singletons/logger';
})
export class AddonBlockTimelineComponent implements OnInit, ICoreBlockComponent {
sort = new FormControl();
sort = new FormControl(AddonBlockTimelineSort.ByDates);
sort$!: Observable<AddonBlockTimelineSort>;
sortOptions!: AddonBlockTimelineOption<AddonBlockTimelineSort>[];
filter = new FormControl();
filter = new FormControl(AddonBlockTimelineFilter.Next30Days);
filter$!: Observable<AddonBlockTimelineFilter>;
statusFilterOptions!: AddonBlockTimelineOption<AddonBlockTimelineFilter>[];
dateFilterOptions!: AddonBlockTimelineOption<AddonBlockTimelineFilter>[];

View File

@ -1,4 +1,5 @@
{
"ariadayfilter": "Filter timeline by date",
"duedate": "Due date",
"next30days": "Next 30 days",
"next3months": "Next 3 months",

View File

@ -57,7 +57,7 @@ Feature: Timeline block.
But I should not find "Assignment 01" within "Timeline" "ion-card" in the app
And I should not find "Course 3" within "Timeline" "ion-card" in the app
When I press "Next 30 days" in the app
When I press "Filter timeline by date" in the app
And I press "Overdue" in the app
Then I should find "Assignment 01" within "Timeline" "ion-card" in the app
And I should find "Course 2" within "Timeline" "ion-card" in the app
@ -66,7 +66,7 @@ Feature: Timeline block.
And I should not find "Course 1" within "Timeline" "ion-card" in the app
And I should not find "Course 3" within "Timeline" "ion-card" in the app
When I press "Overdue" in the app
When I press "Filter timeline by date" in the app
And I press "All" in the app
Then I should find "Assignment 19" within "Timeline" "ion-card" in the app
And I should find "Course 3" within "Timeline" "ion-card" in the app
@ -76,7 +76,7 @@ Feature: Timeline block.
Then I should find "Assignment 21" within "Timeline" "ion-card" in the app
And I should find "Assignment 25" within "Timeline" "ion-card" in the app
When I press "All" in the app
When I press "Filter timeline by date" in the app
And I press "Next 7 days" in the app
And I press "Sort by" in the app
And I press "Sort by courses" in the app

View File

@ -1,37 +1,36 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate"></ion-back-button>
<ion-back-button [text]="'core.back' | translate" />
</ion-buttons>
<ion-title>
<h1>{{ title | translate }}</h1>
</ion-title>
<ion-buttons slot="end">
<core-user-menu-button></core-user-menu-button>
<core-user-menu-button />
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="limited-width">
<ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="refresh($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}" />
</ion-refresher>
<core-loading [hideUntil]="loaded">
<ion-item *ngIf="showMyEntriesToggle">
<ion-label>{{ 'addon.blog.showonlyyourentries' | translate }}</ion-label>
<ion-toggle [(ngModel)]="onlyMyEntries" (ionChange)="onlyMyEntriesToggleChanged(onlyMyEntries)" slot="end"></ion-toggle>
<ion-toggle [(ngModel)]="onlyMyEntries" (ionChange)="onlyMyEntriesToggleChanged(onlyMyEntries)">
{{ 'addon.blog.showonlyyourentries' | translate }}
</ion-toggle>
</ion-item>
<core-empty-box *ngIf="entries && entries.length == 0" icon="far-newspaper" [message]="'addon.blog.noentriesyet' | translate">
</core-empty-box>
<core-empty-box *ngIf="entries && entries.length === 0" icon="far-newspaper" [message]="'addon.blog.noentriesyet' | translate" />
<ng-container *ngFor="let entry of entries">
<ion-card *ngIf="!onlyMyEntries || entry.userid == currentUserId">
<ion-card *ngIf="!onlyMyEntries || entry.userid === currentUserId">
<ion-item class="ion-text-wrap">
<core-user-avatar [user]="entry.user" slot="start" [courseId]="entry.courseid"></core-user-avatar>
<core-user-avatar [user]="entry.user" slot="start" [courseId]="entry.courseid" />
<ion-label>
<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>
[contextInstanceId]="contextInstanceId" />
</h2>
<ion-note class="ion-text-end">
{{ 'addon.blog.' + entry.publishTranslated! | translate}}
@ -49,35 +48,32 @@
<ion-item class="ion-text-wrap">
<ion-label>
<core-format-text [text]="entry.summary" [component]="this.component" [componentId]="entry.id"
[contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId">
</core-format-text>
[contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" />
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="tagsEnabled && entry.tags && entry.tags!.length > 0">
<ion-label>
<div slot="start">{{ 'core.tag.tags' | translate }}:</div>
<core-tag-list [tags]="entry.tags"></core-tag-list>
<core-tag-list [tags]="entry.tags" />
</ion-label>
</ion-item>
<core-comments *ngIf="commentsEnabled" [component]="this.component" [itemId]="entry.id" area="format_blog"
[instanceId]="entry.userid" contextLevel="user" [showItem]="true">
</core-comments>
[instanceId]="entry.userid" contextLevel="user" [showItem]="true" />
<core-file *ngFor="let file of entry.attachmentfiles" [file]="file" [component]="this.component"
[componentId]="entry.id">
</core-file>
[componentId]="entry.id" />
<ion-item *ngIf="entry.uniquehash" [href]="entry.uniquehash" core-link [detail]="true">
<ion-label>{{ 'addon.blog.linktooriginalentry' | translate }}</ion-label>
</ion-item>
</ion-card-content>
<div class="ion-text-center ion-margin-bottom" *ngIf="entry.lastmodified > entry.created">
<ion-note>
<ion-icon name="fas-clock" [attr.aria-label]="'core.lastmodified' | translate"></ion-icon> {{entry.lastmodified
<ion-icon name="fas-clock" [attr.aria-label]="'core.lastmodified' | translate" /> {{entry.lastmodified
|
coreTimeAgo}}
</ion-note>
</div>
</ion-card>
</ng-container>
<core-infinite-loading [enabled]="canLoadMore" (action)="loadMore($event)" [error]="loadMoreError"></core-infinite-loading>
<core-infinite-loading [enabled]="canLoadMore" (action)="loadMore($event)" [error]="loadMoreError" />
</core-loading>
</ion-content>

View File

@ -2,8 +2,7 @@
<core-navbar-buttons slot="end" prepend>
<core-context-menu>
<core-context-menu-item *ngIf="canNavigate && !selectedMonthIsCurrent() && displayNavButtons" [priority]="900"
[content]="'addon.calendar.currentmonth' | translate" iconAction="fas-calendar-day" (action)="goToCurrentMonth()">
</core-context-menu-item>
[content]="'addon.calendar.currentmonth' | translate" iconAction="fas-calendar-day" (action)="goToCurrentMonth()" />
</core-context-menu>
</core-navbar-buttons>
@ -14,19 +13,18 @@
<ion-row class="ion-align-items-center">
<ion-col class="ion-text-start" *ngIf="canNavigate">
<ion-button fill="clear" (click)="loadPrevious()" [attr.aria-label]="'core.previous' | translate">
<ion-icon name="fas-chevron-left" slot="icon-only" aria-hidden="true"></ion-icon>
<ion-icon name="fas-chevron-left" slot="icon-only" aria-hidden="true" />
</ion-button>
</ion-col>
<ion-col class="ion-text-center addon-calendar-period">
<h2 id="addon-calendar-monthname">
{{ periodName }}
<ion-spinner *ngIf="!selectedMonthLoaded()" class="addon-calendar-loading-month">
</ion-spinner>
<ion-spinner *ngIf="!selectedMonthLoaded()" class="addon-calendar-loading-month" />
</h2>
</ion-col>
<ion-col class="ion-text-end" *ngIf="canNavigate">
<ion-button fill="clear" (click)="loadNext()" [attr.aria-label]="'core.next' | translate">
<ion-icon name="fas-chevron-right" slot="icon-only" aria-hidden="true"></ion-icon>
<ion-icon name="fas-chevron-right" slot="icon-only" aria-hidden="true" />
</ion-button>
</ion-col>
</ion-row>
@ -50,8 +48,7 @@
<!-- Weeks. -->
<ion-row *ngFor="let week of month.weeks" class="addon-calendar-week" role="row">
<!-- Empty slots (first week). -->
<ion-col *ngFor="let value of week.prepadding" class="dayblank addon-calendar-day" role="cell">
</ion-col>
<ion-col *ngFor="let value of week.prepadding" class="dayblank addon-calendar-day" role="cell" />
<ion-col *ngFor="let day of week.days" class="addon-calendar-day ion-text-center" [ngClass]='{
"hasevents": day.hasevents,
"today": month.isCurrentMonth && day.istoday,
@ -71,14 +68,14 @@
<!-- In tablet, display list of events. -->
<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"
<div *ngIf="index < 3 || day.filteredEvents.length === 4" class="addon-calendar-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>
[attr.aria-label]="'core.notsent' | translate" />
<ion-icon *ngIf="event.deleted" name="fas-trash"
[attr.aria-label]="'core.deletedoffline' | translate"></ion-icon>
[attr.aria-label]="'core.deletedoffline' | translate" />
<span class="addon-calendar-event-time">
{{ event.timestart * 1000 | coreFormatDate: timeFormat }}
</span>
@ -98,8 +95,7 @@
</div>
</ion-col>
<!-- Empty slots (last week). -->
<ion-col *ngFor="let value of week.postpadding" class="dayblank addon-calendar-day" role="cell">
</ion-col>
<ion-col *ngFor="let value of week.postpadding" class="dayblank addon-calendar-day" role="cell" />
</ion-row>
</div>
</ion-grid>

View File

@ -142,7 +142,7 @@
}
}
ion-slide {
swiper-slide {
display: block;
font-size: inherit;
justify-content: start;

View File

@ -64,7 +64,7 @@ import { Translate } from '@singletons';
})
export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestroy {
@ViewChild(CoreSwipeSlidesComponent) slides?: CoreSwipeSlidesComponent<PreloadedMonth>;
@ViewChild(CoreSwipeSlidesComponent) swipeSlidesComponent?: CoreSwipeSlidesComponent<PreloadedMonth>;
@Input() initialYear?: number; // Initial year to load.
@Input() initialMonth?: number; // Initial month to load.
@ -142,7 +142,7 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro
}
/**
* Component loaded.
* @inheritdoc
*/
ngOnInit(): void {
this.canNavigate = typeof this.canNavigate == 'undefined' ? true : CoreUtils.isTrueOrOne(this.canNavigate);
@ -164,7 +164,7 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro
}
/**
* Detect and act upon changes that Angular cant or wont detect on its own (objects and arrays).
* @inheritdoc
*/
ngDoCheck(): void {
const items = this.manager?.getSource().getItems();
@ -185,7 +185,7 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro
this.hiddenDiffer = this.hidden;
if (!this.hidden) {
this.slides?.slides?.getSwiper().then(swipper => swipper.update());
this.swipeSlidesComponent?.updateSlidesComponent();
}
}
}
@ -248,14 +248,14 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro
* Load next month.
*/
loadNext(): void {
this.slides?.slideNext();
this.swipeSlidesComponent?.slideNext();
}
/**
* Load previous month.
*/
loadPrevious(): void {
this.slides?.slidePrev();
this.swipeSlidesComponent?.slidePrev();
}
/**
@ -343,8 +343,7 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro
*/
async viewMonth(month: number, year: number): Promise<void> {
const manager = this.manager;
const slides = this.slides;
if (!manager || !slides) {
if (!manager || !this.swipeSlidesComponent) {
return;
}
@ -360,7 +359,7 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro
// Make sure the day is loaded.
await manager.getSource().loadItem(item);
slides.slideToItem(item);
this.swipeSlidesComponent.slideToItem(item);
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true);
} finally {
@ -369,7 +368,7 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro
}
/**
* Component destroyed.
* @inheritdoc
*/
ngOnDestroy(): void {
this.undeleteEventObserver?.off();

View File

@ -2,7 +2,7 @@
<ion-toolbar>
<ion-buttons slot="end">
<ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
<ion-icon name="fas-xmark" slot="icon-only" aria-hidden=true></ion-icon>
<ion-icon name="fas-xmark" slot="icon-only" aria-hidden=true />
</ion-button>
</ion-buttons>
</ion-toolbar>
@ -10,18 +10,18 @@
<ion-content [fullscreen]="true">
<ion-list>
<ion-item *ngFor="let type of types" class="addon-calendar-event" [ngClass]="['addon-calendar-eventtype-'+type]">
<ion-icon [name]="typeIcons[type]" slot="start" aria-hidden="true"></ion-icon>
<ion-label>{{ 'addon.calendar.' + type + 'events' | translate}}</ion-label>
<ion-toggle [(ngModel)]="filter[type]" (ionChange)="onChange()" slot="end"></ion-toggle>
<ion-icon [name]="typeIcons[type]" slot="start" aria-hidden="true" />
<ion-toggle [(ngModel)]="filter[type]" (ionChange)="onChange()">
{{ 'addon.calendar.' + type + 'events' | translate}}
</ion-toggle>
</ion-item>
<core-spacer *ngIf="filter.course || filter.category || filter.group"></core-spacer>
<core-spacer *ngIf="filter.course || filter.category || filter.group" />
<ng-container *ngIf="filter.course || filter.category || filter.group">
<ion-radio-group [(ngModel)]="courseId" (ionChange)="onChange()">
<ion-item class="ion-text-wrap" *ngFor="let course of sortedCourses">
<ion-label>
<core-format-text [text]="course.shortname"></core-format-text>
</ion-label>
<ion-radio slot="end" [value]="course.id"></ion-radio>
<ion-radio [value]="course.id">
<core-format-text [text]="course.shortname" />
</ion-radio>
</ion-item>
</ion-radio-group>
</ng-container>

View File

@ -1,6 +1,6 @@
<core-loading [hideUntil]="loaded">
<core-empty-box *ngIf="!filteredEvents || !filteredEvents.length" icon="fas-calendar" [message]="'addon.calendar.noevents' | translate">
</core-empty-box>
<core-empty-box *ngIf="!filteredEvents || !filteredEvents.length" icon="fas-calendar"
[message]="'addon.calendar.noevents' | translate" />
<ion-list *ngIf="filteredEvents && filteredEvents.length" class="list-item-limited-width">
<ng-container *ngFor="let event of filteredEvents">
@ -8,9 +8,8 @@
<ion-item class="ion-text-wrap addon-calendar-event" [attr.aria-label]="event.name" (click)="eventClicked(event)" button
[ngClass]="['addon-calendar-eventtype-'+event.eventtype]" [detail]="false">
<core-mod-icon *ngIf="event.moduleIcon" [modicon]="event.moduleIcon" slot="start" [modname]="event.modulename"
[componentId]="event.instance" [showAlt]="false" [purpose]="event.purpose"></core-mod-icon>
<ion-icon *ngIf="event.eventIcon && !event.moduleIcon" [name]="event.eventIcon" slot="start" aria-hidden="true">
</ion-icon>
[componentId]="event.instance" [showAlt]="false" [purpose]="event.purpose" />
<ion-icon *ngIf="event.eventIcon && !event.moduleIcon" [name]="event.eventIcon" slot="start" aria-hidden="true" />
<ion-label>
<!-- Add the icon title so accessibility tools read it. -->
<span class="sr-only">
@ -19,18 +18,18 @@
</span>
<p class="item-heading">
<core-format-text [text]="event.name" [contextLevel]="event.contextLevel"
[contextInstanceId]="event.contextInstanceId"></core-format-text>
[contextInstanceId]="event.contextInstanceId" />
</p>
<p>
<core-format-text [text]="event.formattedtime" [filter]="false"></core-format-text>
<core-format-text [text]="event.formattedtime" [filter]="false" />
</p>
</ion-label>
<ion-note *ngIf="event.offline && !event.deleted" slot="end">
<ion-icon name="fas-clock" aria-hidden="true"></ion-icon>
<ion-icon name="fas-clock" aria-hidden="true" />
<span class="ion-text-wrap">{{ 'core.notsent' | translate }}</span>
</ion-note>
<ion-note *ngIf="event.deleted" slot="end">
<ion-icon name="fas-trash" aria-hidden="true"></ion-icon>
<ion-icon name="fas-trash" aria-hidden="true" />
<span class="ion-text-wrap">{{ 'core.deletedoffline' | translate }}</span>
</ion-note>
</ion-item>

View File

@ -1,30 +1,28 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate"></ion-back-button>
<ion-back-button [text]="'core.back' | translate" />
</ion-buttons>
<ion-title>
<h1>{{ 'addon.calendar.calendarevents' | translate }}</h1>
</ion-title>
<ion-buttons slot="end">
<ion-button fill="clear" (click)="openFilter()" [attr.aria-label]="'core.filter' | translate">
<ion-icon slot="icon-only" name="fas-filter" aria-hidden="true"></ion-icon>
<ion-icon slot="icon-only" name="fas-filter" aria-hidden="true" />
</ion-button>
<core-context-menu>
<core-context-menu-item *ngIf="!selectedDayIsCurrent()" [priority]="900" [content]="'addon.calendar.today' | translate"
iconAction="fas-calendar-day" (action)="goToCurrentDay()">
</core-context-menu-item>
iconAction="fas-calendar-day" (action)="goToCurrentDay()" />
<core-context-menu-item [hidden]="!loaded || !selectedDayHasOffline() || !isOnline" [priority]="400"
[content]="'core.settings.synchronizenow' | translate" (action)="doRefresh(undefined, $event)" [iconAction]="syncIcon"
[closeOnClick]="false">
</core-context-menu-item>
[closeOnClick]="false" />
</core-context-menu>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="doRefresh($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}" />
</ion-refresher>
<core-loading [hideUntil]="loaded">
@ -34,7 +32,7 @@
<ion-row class="ion-align-items-center">
<ion-col class="ion-text-start">
<ion-button fill="clear" (click)="loadPrevious()" [attr.aria-label]="'addon.calendar.dayprev' | translate">
<ion-icon name="fas-chevron-left" slot="icon-only" aria-hidden="true"></ion-icon>
<ion-icon name="fas-chevron-left" slot="icon-only" aria-hidden="true" />
</ion-button>
</ion-col>
<ion-col class="ion-text-center addon-calendar-period">
@ -42,7 +40,7 @@
</ion-col>
<ion-col class="ion-text-end">
<ion-button fill="clear" (click)="loadNext()" [attr.aria-label]="'addon.calendar.daynext' | translate">
<ion-icon name="fas-chevron-right" slot="icon-only" aria-hidden="true"></ion-icon>
<ion-icon name="fas-chevron-right" slot="icon-only" aria-hidden="true" />
</ion-button>
</ion-col>
</ion-row>
@ -54,14 +52,13 @@
<!-- There is data to be synchronized -->
<ion-card class="core-warning-card list-item-limited-width" *ngIf="day.hasOffline">
<ion-item>
<ion-icon name="fas-triangle-exclamation" slot="start" aria-hidden="true"></ion-icon>
<ion-icon name="fas-triangle-exclamation" slot="start" aria-hidden="true" />
<ion-label>{{ 'core.hasdatatosync' | translate:{$a: 'core.day' | translate} }}</ion-label>
</ion-item>
</ion-card>
<core-empty-box *ngIf="!day.filteredEvents || !day.filteredEvents.length" icon="fas-calendar"
[message]="'addon.calendar.noevents' | translate">
</core-empty-box>
[message]="'addon.calendar.noevents' | translate" />
<ion-list *ngIf="day.filteredEvents && day.filteredEvents.length" class="list-item-limited-width">
<ng-container *ngFor="let event of day.filteredEvents">
@ -70,11 +67,9 @@
(click)="gotoEvent(event.id, day)" [class.item-dimmed]="event.ispast"
[ngClass]="['addon-calendar-eventtype-'+event.eventtype]" button [detail]="false">
<core-mod-icon *ngIf="event.moduleIcon" [modicon]="event.moduleIcon" slot="start" [showAlt]="false"
[modname]="event.modulename" [componentId]="event.instance" [purpose]="event.purpose">
</core-mod-icon>
[modname]="event.modulename" [componentId]="event.instance" [purpose]="event.purpose" />
<ion-icon *ngIf="event.eventIcon && !event.moduleIcon" [name]="event.eventIcon" slot="start"
aria-hidden="true">
</ion-icon>
aria-hidden="true" />
<ion-label>
<!-- Add the icon title so accessibility tools read it. -->
<span class="sr-only">
@ -84,18 +79,18 @@
</span>
<p class="item-heading">
<core-format-text [text]="event.name" [contextLevel]="event.contextLevel"
[contextInstanceId]="event.contextInstanceId"></core-format-text>
[contextInstanceId]="event.contextInstanceId" />
</p>
<p>
<core-format-text [text]="event.formattedtime" [filter]="false"></core-format-text>
<core-format-text [text]="event.formattedtime" [filter]="false" />
</p>
</ion-label>
<ion-note *ngIf="event.offline && !event.deleted" slot="end">
<ion-icon name="fas-clock" aria-hidden="true"></ion-icon>
<ion-icon name="fas-clock" aria-hidden="true" />
<span class="ion-text-wrap">{{ 'core.notsent' | translate }}</span>
</ion-note>
<ion-note *ngIf="event.deleted" slot="end">
<ion-icon name="fas-trash" aria-hidden="true"></ion-icon>
<ion-icon name="fas-trash" aria-hidden="true" />
<span class="ion-text-wrap">{{ 'core.deletedoffline' | translate }}</span>
</ion-note>
</ion-item>
@ -111,7 +106,7 @@
<!-- Create a calendar event. -->
<ion-fab slot="fixed" core-fab vertical="bottom" horizontal="end" *ngIf="canCreate && loaded">
<ion-fab-button (click)="openEdit()" [attr.aria-label]="'addon.calendar.newevent' | translate">
<ion-icon name="fas-plus" aria-hidden="true"></ion-icon>
<ion-icon name="fas-plus" aria-hidden="true" />
<span class="sr-only">{{ 'addon.calendar.newevent' | translate }}</span>
</ion-fab-button>
</ion-fab>

View File

@ -60,20 +60,13 @@ import { CoreTime } from '@singletons/time';
})
export class AddonCalendarDayPage implements OnInit, OnDestroy {
@ViewChild(CoreSwipeSlidesComponent) slides?: CoreSwipeSlidesComponent<PreloadedDay>;
@ViewChild(CoreSwipeSlidesComponent) swipeSlidesComponent?: CoreSwipeSlidesComponent<PreloadedDay>;
protected currentSiteId: string;
// Observers.
protected newEventObserver: CoreEventObserver;
protected discardedObserver: CoreEventObserver;
protected editEventObserver: CoreEventObserver;
protected deleteEventObserver: CoreEventObserver;
protected undeleteEventObserver: CoreEventObserver;
protected syncObserver: CoreEventObserver;
protected manualSyncObserver: CoreEventObserver;
protected eventObservers: CoreEventObserver[] = [];
protected onlineObserver: Subscription;
protected filterChangedObserver: CoreEventObserver;
protected managerUnsubscribe?: () => void;
protected logView: () => void;
@ -97,7 +90,7 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy {
this.currentSiteId = CoreSites.getCurrentSiteId();
// Listen for events added. When an event is added, reload the data.
this.newEventObserver = CoreEvents.on(
this.eventObservers.push(CoreEvents.on(
AddonCalendarProvider.NEW_EVENT_EVENT,
(data) => {
if (data && data.eventId) {
@ -106,16 +99,16 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy {
}
},
this.currentSiteId,
);
));
// Listen for new event discarded event. When it does, reload the data.
this.discardedObserver = CoreEvents.on(AddonCalendarProvider.NEW_EVENT_DISCARDED_EVENT, () => {
this.eventObservers.push(CoreEvents.on(AddonCalendarProvider.NEW_EVENT_DISCARDED_EVENT, () => {
this.manager?.getSource().markAllItemsUnloaded();
this.refreshData(true, true);
}, this.currentSiteId);
}, this.currentSiteId));
// Listen for events edited. When an event is edited, reload the data.
this.editEventObserver = CoreEvents.on(
this.eventObservers.push(CoreEvents.on(
AddonCalendarProvider.EDIT_EVENT_EVENT,
(data) => {
if (data && data.eventId) {
@ -124,25 +117,25 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy {
}
},
this.currentSiteId,
);
));
// Refresh data if calendar events are synchronized automatically.
this.syncObserver = CoreEvents.on(AddonCalendarSyncProvider.AUTO_SYNCED, () => {
this.eventObservers.push(CoreEvents.on(AddonCalendarSyncProvider.AUTO_SYNCED, () => {
this.manager?.getSource().markAllItemsUnloaded();
this.refreshData(false, true);
}, this.currentSiteId);
}, this.currentSiteId));
// Refresh data if calendar events are synchronized manually but not by this page.
this.manualSyncObserver = CoreEvents.on(AddonCalendarSyncProvider.MANUAL_SYNCED, (data) => {
this.eventObservers.push(CoreEvents.on(AddonCalendarSyncProvider.MANUAL_SYNCED, (data) => {
const selectedDay = this.manager?.getSelectedItem();
if (data && (data.source != 'day' || !selectedDay || !data.moment || !selectedDay.moment.isSame(data.moment, 'day'))) {
this.manager?.getSource().markAllItemsUnloaded();
this.refreshData(false, true);
}
}, this.currentSiteId);
}, this.currentSiteId));
// Update the events when an event is deleted.
this.deleteEventObserver = CoreEvents.on(
this.eventObservers.push(CoreEvents.on(
AddonCalendarProvider.DELETED_EVENT_EVENT,
(data) => {
if (data && !data.sent) {
@ -154,10 +147,10 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy {
}
},
this.currentSiteId,
);
));
// Listen for events "undeleted" (offline).
this.undeleteEventObserver = CoreEvents.on(
this.eventObservers.push(CoreEvents.on(
AddonCalendarProvider.UNDELETED_EVENT_EVENT,
(data) => {
if (!data || !data.eventId) {
@ -168,9 +161,9 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy {
this.manager?.getSource().markAsDeleted(data.eventId, false);
},
this.currentSiteId,
);
));
this.filterChangedObserver = CoreEvents.on(
this.eventObservers.push(CoreEvents.on(
AddonCalendarProvider.FILTER_CHANGED_EVENT,
async (data) => {
this.filter = data;
@ -180,7 +173,7 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy {
this.manager?.getSource().filterAllDayEvents(this.filter);
},
);
));
// Refresh online status when changes.
this.onlineObserver = CoreNetwork.onChange().subscribe(() => {
@ -214,7 +207,7 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy {
}
/**
* View loaded.
* @inheritdoc
*/
ngOnInit(): void {
const types: string[] = [];
@ -434,8 +427,7 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy {
*/
async goToCurrentDay(): Promise<void> {
const manager = this.manager;
const slides = this.slides;
if (!manager || !slides) {
if (!manager || !this.swipeSlidesComponent) {
return;
}
@ -448,7 +440,7 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy {
// Make sure the day is loaded.
await manager.getSource().loadItem(currentDay);
slides.slideToItem(currentDay);
this.swipeSlidesComponent.slideToItem(currentDay);
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true);
} finally {
@ -460,29 +452,22 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy {
* Load next day.
*/
async loadNext(): Promise<void> {
this.slides?.slideNext();
this.swipeSlidesComponent?.slideNext();
}
/**
* Load previous day.
*/
async loadPrevious(): Promise<void> {
this.slides?.slidePrev();
this.swipeSlidesComponent?.slidePrev();
}
/**
* Page destroyed.
* @inheritdoc
*/
ngOnDestroy(): void {
this.newEventObserver?.off();
this.discardedObserver?.off();
this.editEventObserver?.off();
this.deleteEventObserver?.off();
this.undeleteEventObserver?.off();
this.syncObserver?.off();
this.manualSyncObserver?.off();
this.eventObservers.forEach((observer) => observer.off());
this.onlineObserver?.unsubscribe();
this.filterChangedObserver?.off();
this.manager?.getSource().forgetRelatedSources();
this.manager?.destroy();
this.managerUnsubscribe?.();

View File

@ -1,7 +1,7 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate"></ion-back-button>
<ion-back-button [text]="'core.back' | translate" />
</ion-buttons>
<ion-title>
<h1>{{ title | translate }}</h1>
@ -10,19 +10,18 @@
</ion-header>
<ion-content>
<ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="refreshData($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}" />
</ion-refresher>
<core-loading [hideUntil]="loaded">
<form [formGroup]="form" *ngIf="!error" #editEventForm>
<!-- Event name. -->
<ion-item class="ion-text-wrap">
<ion-label position="stacked">
<p class="item-heading" [core-mark-required]="true">{{ 'addon.calendar.eventname' | translate }}</p>
</ion-label>
<ion-input type="text" name="name" [placeholder]="'addon.calendar.eventname' | translate" formControlName="name">
<ion-input labelPlacement="stacked" type="text" name="name" [placeholder]="'addon.calendar.eventname' | translate"
formControlName="name">
<div slot="label" [core-mark-required]="true">{{ 'addon.calendar.eventname' | translate }}</div>
</ion-input>
<core-input-errors [control]="form.controls.name" [errorMessages]="errors"></core-input-errors>
<core-input-errors [control]="form.controls.name" />
</ion-item>
<!-- Date. -->
@ -30,20 +29,26 @@
<ion-label position="stacked">
<p class="item-heading" [core-mark-required]="true">{{ 'core.date' | translate }}</p>
</ion-label>
<ion-datetime formControlName="timestart" [placeholder]="'core.date' | translate" [displayFormat]="dateFormat"
[max]="maxDate" [min]="minDate" [displayTimezone]="displayTimezone">
</ion-datetime>
<core-input-errors [control]="form.controls.timestart" [errorMessages]="errors"></core-input-errors>
<ion-datetime-button datetime="timestart" />
<ion-modal [keepContentsMounted]="true">
<ng-template>
<ion-datetime id="timestart" formControlName="timestart" presentation="date-time" [max]="maxDate" [min]="minDate">
<span slot="title">{{'core.date' | translate}}</span>
</ion-datetime>
</ng-template>
</ion-modal>
<core-input-errors [control]="form.controls.timestart" />
</ion-item>
<!-- Type. -->
<ion-item class="ion-text-wrap addon-calendar-eventtype-container">
<ion-label>
<ion-label *ngIf="eventTypes.length === 1">
<p class="item-heading" [core-mark-required]="true">{{ 'addon.calendar.eventkind' | translate }}</p>
</ion-label>
<p *ngIf="eventTypes.length == 1" slot="end">{{eventTypes[0].name | translate }}</p>
<p *ngIf="eventTypes.length === 1" slot="end">{{eventTypes[0].name | translate }}</p>
<ion-select *ngIf="eventTypes.length > 1" formControlName="eventtype" interface="action-sheet"
[cancelText]="'core.cancel' | translate" [interfaceOptions]="{header: 'addon.calendar.eventkind' | translate}">
<div [core-mark-required]="true" slot="label">{{ 'addon.calendar.eventkind' | translate }}</div>
<ion-select-option *ngFor="let type of eventTypes" [value]="type.value">
{{ type.name | translate }}
</ion-select-option>
@ -52,11 +57,9 @@
<!-- Category. -->
<ion-item class="ion-text-wrap" *ngIf="typeControl.value === 'category'">
<ion-label>
<p class="item-heading" [core-mark-required]="true">{{ 'core.category' | translate }}</p>
</ion-label>
<ion-select formControlName="categoryid" interface="action-sheet" [placeholder]="'core.noselection' | translate"
[cancelText]="'core.cancel' | translate" [interfaceOptions]="{header: 'core.category' | translate}">
<p [core-mark-required]="true" slot="label">{{ 'core.category' | translate }}</p>
<ion-select-option *ngFor="let category of categories" [value]="category.id">
{{ category.name }}
</ion-select-option>
@ -65,11 +68,9 @@
<!-- Course. -->
<ion-item class="ion-text-wrap" *ngIf="typeControl.value === 'course'">
<ion-label>
<p class="item-heading" [core-mark-required]="true">{{ 'core.course' | translate }}</p>
</ion-label>
<ion-select formControlName="courseid" interface="action-sheet" [placeholder]="'core.noselection' | translate"
[cancelText]="'core.cancel' | translate" [interfaceOptions]="{header: 'core.course' | translate}">
<p [core-mark-required]="true" slot="label">{{ 'core.course' | translate }}</p>
<ion-select-option *ngFor="let course of courses" [value]="course.id">{{ course.fullname }}</ion-select-option>
</ion-select>
</ion-item>
@ -78,12 +79,10 @@
<ng-container *ngIf="typeControl.value === 'group'">
<!-- Select the course. -->
<ion-item class="ion-text-wrap">
<ion-label>
<p class="item-heading" [core-mark-required]="true">{{ 'core.course' | translate }}</p>
</ion-label>
<ion-select formControlName="groupcourseid" interface="action-sheet" [placeholder]="'core.noselection' | translate"
[cancelText]="'core.cancel' | translate" (ionChange)="groupCourseSelected()"
[interfaceOptions]="{header: 'core.course' | translate}">
<p [core-mark-required]="true" slot="label">{{ 'core.course' | translate }}</p>
<ion-select-option *ngFor="let course of courses" [value]="course.id">
{{ course.fullname }}
</ion-select-option>
@ -97,18 +96,16 @@
</ion-item>
<!-- Select the group. -->
<ion-item class="ion-text-wrap core-edit-set-group" *ngIf="!loadingGroups && groups.length > 0">
<ion-label>
<p class="item-heading" [core-mark-required]="true">{{ 'core.group' | translate }}</p>
</ion-label>
<ion-select formControlName="groupid" interface="action-sheet" [placeholder]="'core.noselection' | translate"
[cancelText]="'core.cancel' | translate" [interfaceOptions]="{header: 'core.group' | translate}">
<p [core-mark-required]="true" slot="label">{{ 'core.group' | translate }}</p>
<ion-select-option *ngFor="let group of groups" [value]="group.id">{{ group.name }}</ion-select-option>
</ion-select>
</ion-item>
<!-- Loading groups. -->
<ion-item class="ion-text-wrap" *ngIf="loadingGroups">
<ion-label>
<ion-spinner *ngIf="loadingGroups" [attr.aria-label]="'core.loading' | translate"></ion-spinner>
<ion-spinner *ngIf="loadingGroups" [attr.aria-label]="'core.loading' | translate" />
</ion-label>
</ion-item>
</ng-container>
@ -121,7 +118,7 @@
</ion-label>
<ion-button fill="clear" (click)="addReminder()" slot="end"
[attr.aria-label]="'addon.calendar.setnewreminder' | translate">
<ion-icon name="fas-plus" slot="icon-only" aria-hidden="true"></ion-icon>
<ion-icon name="fas-plus" slot="icon-only" aria-hidden="true" />
</ion-button>
</ion-item-divider>
<ion-item *ngFor="let reminder of reminders" class="ion-text-wrap">
@ -129,7 +126,7 @@
<p>{{ reminder.label }}</p>
</ion-label>
<ion-button fill="clear" (click)="removeReminder(reminder)" [attr.aria-label]="'core.delete' | translate" slot="end">
<ion-icon name="fas-trash" color="danger" slot="icon-only" aria-hidden="true"></ion-icon>
<ion-icon name="fas-trash" color="danger" slot="icon-only" aria-hidden="true" />
</ion-button>
</ion-item>
</ng-container>
@ -143,34 +140,35 @@
</ion-label>
</ion-item-divider>
<ion-item>
<ion-label>
<ion-radio [value]="0">
<p>{{ 'addon.calendar.durationnone' | translate }}</p>
</ion-label>
<ion-radio slot="end" [value]="0"></ion-radio>
</ion-radio>
</ion-item>
<ion-item>
<ion-label>
<ion-radio [value]="1">
<p>{{ 'addon.calendar.durationuntil' | translate }}</p>
</ion-label>
<ion-radio slot="end" [value]="1"></ion-radio>
</ion-radio>
</ion-item>
<ion-item *ngIf="form.controls.duration.value === 1">
<ion-label position="stacked"></ion-label>
<ion-datetime formControlName="timedurationuntil" [max]="maxDate" [min]="minDate"
[placeholder]="'addon.calendar.durationuntil' | translate" [displayFormat]="dateFormat"
[displayTimezone]="displayTimezone">
</ion-datetime>
<ion-label position="stacked" />
<ion-datetime-button datetime="timedurationuntil" />
<ion-modal [keepContentsMounted]="true">
<ng-template>
<ion-datetime id="timedurationuntil" formControlName="timedurationuntil" [max]="maxDate" [min]="minDate"
presentation="date-time">
<span slot="title">{{'addon.calendar.durationuntil' | translate}}</span>
</ion-datetime>
</ng-template>
</ion-modal>
</ion-item>
<ion-item>
<ion-label>
<p>{{ 'addon.calendar.durationminutes' | translate }}</p>
</ion-label>
<ion-radio slot="end" [value]="2"></ion-radio>
<ion-radio [value]="2">
<p id="durationinminutes">{{ 'addon.calendar.durationminutes' | translate }}</p>
</ion-radio>
</ion-item>
<ion-item *ngIf="form.controls.duration.value === 2">
<ion-label class="sr-only">{{ 'addon.calendar.durationminutes' | translate }}</ion-label>
<ion-input type="number" name="timedurationminutes" slot="end"
[placeholder]="'addon.calendar.durationminutes' | translate" formControlName="timedurationminutes"></ion-input>
<ion-input type="number" name="timedurationminutes" labelPlacement="start" aria-labelledby="durationinminutes"
[placeholder]="'addon.calendar.durationminutes' | translate" formControlName="timedurationminutes" />
</ion-item>
</ion-radio-group>
</div>
@ -178,17 +176,13 @@
<!-- Repeat (for new events). -->
<ng-container *ngIf="!eventId || eventId < 0">
<ion-item class="ion-text-wrap divider">
<ion-label>
<ion-checkbox labelPlacement="start" formControlName="repeat">
<p class="item-heading">{{ 'addon.calendar.repeatevent' | translate }}</p>
</ion-label>
<ion-checkbox slot="end" formControlName="repeat"></ion-checkbox>
</ion-checkbox>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label position="stacked">
<p class="item-heading">{{ 'addon.calendar.repeatweeksl' | translate }}</p>
</ion-label>
<ion-input type="number" name="repeats" formControlName="repeats" [disabled]="!form.controls.repeat.value">
</ion-input>
<ion-input labelPlacement="stacked" [label]="'addon.calendar.repeatweeksl' | translate" type="number" name="repeats"
formControlName="repeats" [disabled]="!form.controls.repeat.value" />
</ion-item>
</ng-container>
@ -201,16 +195,14 @@
</ion-label>
</ion-item-divider>
<ion-item>
<ion-label>
<ion-radio value="1">
<p>{{ 'addon.calendar.repeateditall' | translate:{$a: otherEventsCount} }}</p>
</ion-label>
<ion-radio slot="end" value="1"></ion-radio>
</ion-radio>
</ion-item>
<ion-item>
<ion-label>
<ion-radio value="0">
<p>{{ 'addon.calendar.repeateditthis' | translate }}</p>
</ion-label>
<ion-radio slot="end" value="0"></ion-radio>
</ion-radio>
</ion-item>
</ion-radio-group>
</div>
@ -222,16 +214,13 @@
</ion-label>
<core-rich-text-editor [control]="descriptionControl" [attr.aria-label]="'core.description' | translate"
[placeholder]="'core.description' | translate" name="description" [component]="component" [componentId]="eventId"
[autoSave]="false"></core-rich-text-editor>
[autoSave]="false" />
</ion-item>
<!-- Location. -->
<ion-item class="ion-text-wrap">
<ion-label position="stacked">
<p class="item-heading">{{ 'core.location' | translate }}</p>
</ion-label>
<ion-input type="text" name="location" [placeholder]="'core.location' | translate" formControlName="location">
</ion-input>
<ion-input type="text" name="location" [placeholder]="'core.location' | translate" [label]="'core.location' | translate"
labelPlacement="stacked" formControlName="location" />
</ion-item>
</form>
<div collapsible-footer appearOnBottom *ngIf="loaded && !error" slot="fixed">

View File

@ -45,7 +45,6 @@ import { CoreForms } from '@singletons/form';
import { CoreReminders, CoreRemindersService, CoreRemindersUnits } from '@features/reminders/services/reminders';
import { CoreRemindersSetReminderMenuComponent } from '@features/reminders/components/set-reminder-menu/set-reminder-menu';
import moment from 'moment-timezone';
import { CoreAppProvider } from '@services/app';
/**
* Page that displays a form to create/edit an event.
@ -61,7 +60,6 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy, CanLeave {
@ViewChild('editEventForm') formElement!: ElementRef;
title = 'addon.calendar.newevent';
dateFormat: string;
component = AddonCalendarProvider.COMPONENT;
loaded = false;
hasOffline = false;
@ -71,20 +69,18 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy, CanLeave {
groups: CoreGroup[] = [];
loadingGroups = false;
courseGroupSet = false;
errors: Record<string, string>;
error = false;
eventRepeatId?: number;
otherEventsCount = 0;
eventId?: number;
maxDate: string;
minDate: string;
displayTimezone?: string;
// Form variables.
form: FormGroup;
typeControl: FormControl;
groupControl: FormControl;
descriptionControl: FormControl;
typeControl: FormControl<AddonCalendarEventType | null>;
groupControl: FormControl<number | null>;
descriptionControl: FormControl<string>;
// Reminders.
remindersEnabled = false;
@ -103,21 +99,13 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy, CanLeave {
) {
this.currentSite = CoreSites.getRequiredCurrentSite();
this.remindersEnabled = CoreReminders.isEnabled();
this.errors = {
required: Translate.instant('core.required'),
};
// Calculate format to use. ion-datetime doesn't support escaping characters ([]), so we remove them.
this.dateFormat = CoreTimeUtils.convertPHPToMoment(Translate.instant('core.strftimedatetimeshort'))
.replace(/[[\]]/g, '');
this.displayTimezone = CoreAppProvider.getForcedTimezone();
this.form = new FormGroup({});
// Initialize form variables.
this.typeControl = this.fb.control('', Validators.required);
this.groupControl = this.fb.control('');
this.descriptionControl = this.fb.control('');
this.typeControl = this.fb.control(null, Validators.required);
this.groupControl = this.fb.control(null);
this.descriptionControl = this.fb.control('', { nonNullable: true });
this.form.addControl('name', this.fb.control('', Validators.required));
this.form.addControl('eventtype', this.typeControl);
this.form.addControl('categoryid', this.fb.control(''));
@ -334,11 +322,11 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy, CanLeave {
this.form.controls.name.setValue(event.name);
this.form.controls.timestart.setValue(CoreTimeUtils.toDatetimeFormat(event.timestart * 1000));
this.form.controls.eventtype.setValue(event.eventtype);
this.typeControl.setValue(event.eventtype as AddonCalendarEventType);
this.form.controls.categoryid.setValue(event.categoryid || '');
this.form.controls.courseid.setValue(courseId || '');
this.form.controls.groupcourseid.setValue(courseId || '');
this.form.controls.groupid.setValue(event.groupid || '');
this.groupControl.setValue(event.groupid || null);
this.form.controls.description.setValue(event.description);
this.form.controls.location.setValue(event.location);
@ -422,7 +410,7 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy, CanLeave {
try {
await this.loadGroups(courseId);
this.groupControl.setValue('');
this.groupControl.setValue(null);
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'Error getting data.');
}

View File

@ -1,42 +1,38 @@
<ion-header collapsible>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate"></ion-back-button>
<ion-back-button [text]="'core.back' | translate" />
</ion-buttons>
<ion-title>
<h1 *ngIf="event">
<core-format-text [text]="event.name" [contextLevel]="event.contextLevel" [contextInstanceId]="event.contextInstanceId">
</core-format-text>
<core-format-text [text]="event.name" [contextLevel]="event.contextLevel" [contextInstanceId]="event.contextInstanceId" />
</h1>
</ion-title>
<ion-buttons slot="end">
<core-context-menu>
<core-context-menu-item [hidden]="!eventLoaded || (!hasOffline && event && !event.deleted) || !isOnline" [priority]="400"
[content]="'core.settings.synchronizenow' | translate" (action)="doRefresh(undefined, $event, true)"
[iconAction]="syncIcon" [closeOnClick]="false">
</core-context-menu-item>
[iconAction]="syncIcon" [closeOnClick]="false" />
<core-context-menu-item [hidden]="!event || !event.canedit || event.deleted || (!canEdit && event.id > 0)" [priority]="300"
[content]="'core.edit' | translate" (action)="openEdit()" iconAction="fas-pen">
</core-context-menu-item>
[content]="'core.edit' | translate" (action)="openEdit()" iconAction="fas-pen" />
<core-context-menu-item [hidden]="!event || !event.candelete || event.deleted" [priority]="200"
[content]="'core.delete' | translate" (action)="deleteEvent()" iconAction="fas-trash"></core-context-menu-item>
[content]="'core.delete' | translate" (action)="deleteEvent()" iconAction="fas-trash" />
<core-context-menu-item [hidden]="!event || !event.deleted" [priority]="200" [content]="'core.restore' | translate"
(action)="undoDelete()" iconAction="fas-rotate-left"></core-context-menu-item>
(action)="undoDelete()" iconAction="fas-rotate-left" />
</core-context-menu>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content [core-swipe-navigation]="events">
<ion-refresher slot="fixed" [disabled]="!eventLoaded" (ionRefresh)="doRefresh($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}" />
</ion-refresher>
<core-loading [hideUntil]="eventLoaded">
<ion-list *ngIf="event">
<ion-item class="ion-text-wrap addon-calendar-event" collapsible [ngClass]="['addon-calendar-eventtype-'+event.eventtype]">
<core-mod-icon *ngIf="event.moduleIcon" [modicon]="event.moduleIcon" [showAlt]="false" [modname]="event.modulename"
[componentId]="event.instance" slot="start" [purpose]="event.purpose"></core-mod-icon>
<ion-icon *ngIf=" event.eventIcon && !event.moduleIcon" [name]="event.eventIcon" aria-hidden="true" slot="start">
</ion-icon>
[componentId]="event.instance" slot="start" [purpose]="event.purpose" />
<ion-icon *ngIf=" event.eventIcon && !event.moduleIcon" [name]="event.eventIcon" aria-hidden="true" slot="start" />
<ion-label>
<!-- Add the icon title so accessibility tools read it. -->
<span class="sr-only">
@ -45,25 +41,24 @@
</span>
<h1>
<core-format-text [text]="event.name" [contextLevel]="event.contextLevel"
[contextInstanceId]="event.contextInstanceId">
</core-format-text>
[contextInstanceId]="event.contextInstanceId" />
</h1>
</ion-label>
</ion-item>
<!-- There is data to be synchronized -->
<ion-card class="core-warning-card" *ngIf="hasOffline || event.deleted">
<ion-item>
<ion-icon name="fas-triangle-exclamation" slot="start" aria-hidden="true"></ion-icon>
<ion-icon name="fas-triangle-exclamation" slot="start" aria-hidden="true" />
<ion-label>{{ 'core.hasdatatosync' | translate:{$a: 'addon.calendar.calendarevent' | translate} }}</ion-label>
</ion-item>
</ion-card>
<ion-item class="ion-text-wrap">
<ion-label>
<p class="item-heading">{{ 'addon.calendar.when' | translate }}</p>
<core-format-text [text]="event.formattedtime" [filter]="false"></core-format-text>
<core-format-text [text]="event.formattedtime" [filter]="false" />
</ion-label>
<ion-note slot="end" *ngIf="event.deleted">
<ion-icon name="fas-trash" aria-hidden="true"></ion-icon> {{ 'core.deletedoffline' | translate }}
<ion-icon name="fas-trash" aria-hidden="true" /> {{ 'core.deletedoffline' | translate }}
</ion-note>
</ion-item>
<ion-item>
@ -76,8 +71,7 @@
<ion-label>
<p class="item-heading">{{ 'core.course' | translate}}</p>
<p>
<core-format-text [text]="courseName" contextLevel="course" [contextInstanceId]="courseId">
</core-format-text>
<core-format-text [text]="courseName" contextLevel="course" [contextInstanceId]="courseId" />
</p>
</ion-label>
</ion-item>
@ -85,8 +79,7 @@
<ion-label>
<p class="item-heading">{{ 'core.group' | translate}}</p>
<p>
<core-format-text [text]="groupName" contextLevel="course" [contextInstanceId]="event.courseid">
</core-format-text>
<core-format-text [text]="groupName" contextLevel="course" [contextInstanceId]="event.courseid" />
</p>
</ion-label>
</ion-item>
@ -94,8 +87,7 @@
<ion-label>
<p class="item-heading">{{ 'core.category' | translate}}</p>
<p>
<core-format-text [text]="categoryPath" contextLevel="coursecat" [contextInstanceId]="event.categoryid">
</core-format-text>
<core-format-text [text]="categoryPath" contextLevel="coursecat" [contextInstanceId]="event.categoryid" />
</p>
</ion-label>
</ion-item>
@ -104,7 +96,7 @@
<p class="item-heading">{{ 'core.description' | translate}}</p>
<p>
<core-format-text [text]="event.description" [contextLevel]="event.contextLevel"
[contextInstanceId]="event.contextInstanceId"></core-format-text>
[contextInstanceId]="event.contextInstanceId" />
</p>
</ion-label>
</ion-item>
@ -114,7 +106,7 @@
<p>
<a [href]="event.encodedLocation" core-link auto-login="no">
<core-format-text [text]="event.location" [contextLevel]="event.contextLevel"
[contextInstanceId]="event.contextInstanceId"></core-format-text>
[contextInstanceId]="event.contextInstanceId" />
</a>
</p>
</ion-label>
@ -142,7 +134,7 @@
</ion-label>
<ion-button fill="clear" (click)="deleteReminder(reminder.id, $event)" [attr.aria-label]="'core.delete' | translate"
slot="end">
<ion-icon name="fas-trash" color="danger" slot="icon-only" aria-hidden="true"></ion-icon>
<ion-icon name="fas-trash" color="danger" slot="icon-only" aria-hidden="true" />
</ion-button>
</ion-item>
</ng-container>

View File

@ -639,8 +639,10 @@ class AddonCalendarEventsSwipeItemsManager extends CoreSwipeNavigationItemsManag
/**
* @inheritdoc
*/
protected getSelectedItemPathFromRoute(route: ActivatedRouteSnapshot): string | null {
return route.params.id;
protected getSelectedItemPathFromRoute(route: ActivatedRouteSnapshot | ActivatedRoute): string | null {
const snapshot = route instanceof ActivatedRouteSnapshot ? route : route.snapshot;
return snapshot.params.id;
}
}

View File

@ -1,55 +1,52 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate"></ion-back-button>
<ion-back-button [text]="'core.back' | translate" />
</ion-buttons>
<ion-title>
<h1>{{ (showCalendar ? 'addon.calendar.calendarevents' : 'addon.calendar.upcomingevents') | translate }}</h1>
</ion-title>
<ion-buttons slot="end">
<ion-button fill="clear" (click)="openFilter()" [attr.aria-label]="'core.filter' | translate">
<ion-icon slot="icon-only" name="fas-filter" aria-hidden="true"></ion-icon>
<ion-icon slot="icon-only" name="fas-filter" aria-hidden="true" />
</ion-button>
<core-context-menu>
<core-context-menu-item *ngIf="showCalendar" [priority]="800" [content]="'addon.calendar.upcomingevents' | translate"
iconAction="fas-table-list" (action)="toggleDisplay()"></core-context-menu-item>
iconAction="fas-table-list" (action)="toggleDisplay()" />
<core-context-menu-item *ngIf="!showCalendar" [priority]="800" [content]="'addon.calendar.monthlyview' | translate"
iconAction="fas-calendar-days" (action)="toggleDisplay()"></core-context-menu-item>
iconAction="fas-calendar-days" (action)="toggleDisplay()" />
<core-context-menu-item [priority]="600" [content]="'core.settings.settings' | translate" (action)="openSettings()"
iconAction="fas-gears">
</core-context-menu-item>
iconAction="fas-gears" />
<core-context-menu-item [hidden]="!loaded || !hasOffline || !isOnline" [priority]="400"
[content]="'core.settings.synchronizenow' | translate" (action)="doRefresh(undefined, $event, true)"
[iconAction]="syncIcon" [closeOnClick]="false"></core-context-menu-item>
[iconAction]="syncIcon" [closeOnClick]="false" />
</core-context-menu>
<core-user-menu-button></core-user-menu-button>
<core-user-menu-button />
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="doRefresh($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}" />
</ion-refresher>
<!-- There is data to be synchronized -->
<ion-card class="core-warning-card" *ngIf="hasOffline">
<ion-item>
<ion-icon name="fas-triangle-exclamation" slot="start" aria-hidden="true"></ion-icon>
<ion-icon name="fas-triangle-exclamation" slot="start" aria-hidden="true" />
<ion-label>{{ 'core.hasdatatosync' | translate:{$a: 'addon.calendar.calendar' | translate} }}</ion-label>
</ion-item>
</ion-card>
<addon-calendar-calendar [hidden]="!showCalendar" [initialYear]="year" [initialMonth]="month" [filter]="filter"
[displayNavButtons]="showCalendar" (onEventClicked)="gotoEvent($event)" (onDayClicked)="gotoDay($event)">
</addon-calendar-calendar>
[displayNavButtons]="showCalendar" (onEventClicked)="gotoEvent($event)" (onDayClicked)="gotoDay($event)" />
<addon-calendar-upcoming-events *ngIf="loadUpcoming" [hidden]="showCalendar" [filter]="filter" (onEventClicked)="gotoEvent($event)">
</addon-calendar-upcoming-events>
<addon-calendar-upcoming-events *ngIf="loadUpcoming" [hidden]="showCalendar" [filter]="filter" (onEventClicked)="gotoEvent($event)" />
<!-- Create a calendar event. -->
<ion-fab slot="fixed" core-fab vertical="bottom" horizontal="end" *ngIf="canCreate">
<ion-fab-button (click)="openEdit()" [attr.aria-label]="'addon.calendar.newevent' | translate">
<ion-icon name="fas-plus" aria-hidden="true"></ion-icon>
<ion-icon name="fas-plus" aria-hidden="true" />
<span class="sr-only">{{ 'addon.calendar.newevent' | translate }}</span>
</ion-fab-button>
</ion-fab>

View File

@ -1,7 +1,7 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate"></ion-back-button>
<ion-back-button [text]="'core.back' | translate" />
</ion-buttons>
<ion-title>
<h1>{{ 'core.settings.settings' | translate }}</h1>
@ -11,8 +11,9 @@
<ion-content>
<ion-list>
<ion-item *ngIf="defaultTimeLabel">
<ion-label>{{ 'addon.calendar.defaultnotificationtime' | translate }}</ion-label>
<ion-select [(ngModel)]="defaultTimeLabel" (click)="changeDefaultTime($event)">
<ion-select [(ngModel)]="defaultTimeLabel" (click)="changeDefaultTime($event)"
[label]="'addon.calendar.defaultnotificationtime' | translate">
<ion-select-option [value]="defaultTimeLabel">{{ defaultTimeLabel }}</ion-select-option>
</ion-select>
</ion-item>

View File

@ -20,12 +20,14 @@ Feature: Test creation of calendar events in app
| teacher1 | C1 | editingteacher |
| student1 | C1 | student |
# This test is flaky due to timestamp.
Scenario: Create user event as student from monthly view
Given I entered the app as "student1"
When I press "More" in the app
And I press "Calendar" in the app
And I press "New event" in the app
Then the field "Date" matches value "## now ##%d/%m/%y, %H:%M##" in the app
# Flaky step, sometimes it fails due to minute change when checking.
Then the field "Date" matches value "## now ##%Y-%m-%dT%H:%M##" in the app
And I should not be able to press "Save" in the app
# Check that student can only create User events.
@ -35,7 +37,7 @@ Feature: Test creation of calendar events in app
# Create the event.
When I set the field "Event title" to "User Event 01" in the app
And I set the field "Date" to "2025-04-11T09:00+08:00" in the app
And I set the field "Date" to "2025-04-11T09:00" in the app
And I press "Without duration" in the app
And I set the field "Description" to "This is User Event 01 description." in the app
And I set the field "Location" to "Barcelona" in the app

View File

@ -1,12 +1,11 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate"></ion-back-button>
<ion-back-button [text]="'core.back' | translate" />
</ion-buttons>
<ion-title>
<h1>
<core-format-text [text]="title" [contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId">
</core-format-text>
<core-format-text [text]="title" [contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" />
</h1>
</ion-title>
</ion-toolbar>
@ -14,7 +13,7 @@
<ion-content>
<core-split-view>
<ion-refresher slot="fixed" [disabled]="!competencies.loaded" (ionRefresh)="refreshCompetencies($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}" />
</ion-refresher>
<core-loading [hideUntil]="competencies.loaded">
<ion-list>
@ -24,8 +23,7 @@
<ion-label>
<p class="item-heading">
<core-format-text [text]="competency.competency.shortname" [contextLevel]="contextLevel"
[contextInstanceId]="contextInstanceId">
</core-format-text> <em>{{competency.competency.idnumber}}</em>
[contextInstanceId]="contextInstanceId" /> <em>{{competency.competency.idnumber}}</em>
</p>
</ion-label>
<ion-badge slot="end" *ngIf="competency.usercompetency"

View File

@ -1,25 +1,24 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate"></ion-back-button>
<ion-back-button [text]="'core.back' | translate" />
</ion-buttons>
<ion-title>
<h1 *ngIf="competency">
<core-format-text [text]="competency.competency.competency.shortname" [contextLevel]="contextLevel"
[contextInstanceId]="contextInstanceId">
</core-format-text> <small>{{ competency.competency.competency.idnumber }}</small>
[contextInstanceId]="contextInstanceId" /> <small>{{ competency.competency.competency.idnumber }}</small>
</h1>
</ion-title>
</ion-toolbar>
</ion-header>
<ion-content [core-swipe-navigation]="competencies" class="limited-width">
<ion-refresher slot="fixed" [disabled]="!competencyLoaded" (ionRefresh)="refreshCompetency($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}" />
</ion-refresher>
<core-loading [hideUntil]="competencyLoaded">
<ion-card *ngIf="user">
<ion-item class="ion-text-wrap">
<core-user-avatar [user]="user" slot="start"></core-user-avatar>
<core-user-avatar [user]="user" slot="start" />
<ion-label>
<p class="item-heading">{{ user.fullname }}</p>
</ion-label>
@ -30,8 +29,7 @@
<ion-item class="ion-text-wrap" *ngIf="competency.competency.competency.description">
<ion-label>
<core-format-text [text]="competency.competency.competency.description" [contextLevel]="contextLevel"
[contextInstanceId]="contextInstanceId">
</core-format-text>
[contextInstanceId]="contextInstanceId" />
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap only-links">
@ -40,26 +38,22 @@
<p>
<a *ngIf="competency.competency.comppath.showlinks" [href]="competencyFrameworkUrl" core-link>
<core-format-text [text]="competency.competency.comppath.framework.name" [contextLevel]="contextLevel"
[contextInstanceId]="contextInstanceId">
</core-format-text>
[contextInstanceId]="contextInstanceId" />
</a>
<ng-container *ngIf="!competency.competency.comppath.showlinks">
<core-format-text [text]="competency.competency.comppath.framework.name" [contextLevel]="contextLevel"
[contextInstanceId]="contextInstanceId">
</core-format-text>
[contextInstanceId]="contextInstanceId" />
</ng-container>
&nbsp;/&nbsp;
<ng-container *ngFor="let ancestor of competency.competency.comppath.ancestors">
<button *ngIf="competency.competency.comppath.showlinks" (click)="openCompetencySummary(ancestor.id)"
class="as-link">
<core-format-text [text]="ancestor.name" [contextLevel]="contextLevel"
[contextInstanceId]="contextInstanceId">
</core-format-text>
[contextInstanceId]="contextInstanceId" />
</button>
<ng-container *ngIf="!competency.competency.comppath.showlinks">
<core-format-text [text]="ancestor.name" [contextLevel]="contextLevel"
[contextInstanceId]="contextInstanceId">
</core-format-text>
[contextInstanceId]="contextInstanceId" />
</ng-container>
<ng-container *ngIf="!ancestor.last">&nbsp;/&nbsp;</ng-container>
</ng-container>
@ -76,8 +70,7 @@
<p *ngFor="let relatedcomp of competency.competency.relatedcompetencies">
<button (click)="openCompetencySummary(relatedcomp.id)" class="as-link">
<core-format-text [text]="relatedcomp.shortname" [contextLevel]="contextLevel"
[contextInstanceId]="contextInstanceId">
</core-format-text> - {{ relatedcomp.idnumber }}
[contextInstanceId]="contextInstanceId" /> - {{ relatedcomp.idnumber }}
</button>
</p>
</ng-container>
@ -86,17 +79,15 @@
<ion-item class="ion-text-wrap" *ngIf="coursemodules">
<ion-label>
<p class="item-heading">{{ 'addon.competency.activities' | translate }}</p>
<p *ngIf="coursemodules.length == 0">
<p *ngIf="coursemodules.length === 0">
{{ 'addon.competency.noactivities' | translate }}
</p>
<ion-item class="ion-text-wrap" *ngFor="let activity of coursemodules" [href]="activity.url"
[attr.aria-label]="activity.name" core-link capture="true">
<core-mod-icon slot="start" [modicon]="activity.iconurl" [showAlt]="false" *ngIf="activity.iconurl">
</core-mod-icon>
<core-mod-icon slot="start" [modicon]="activity.iconurl" [showAlt]="false" *ngIf="activity.iconurl" />
<ion-label>
<core-format-text [text]="activity.name" contextLevel="module" [contextInstanceId]="activity.id"
[courseId]="courseId">
</core-format-text>
[courseId]="courseId" />
</ion-label>
</ion-item>
</ion-label>
@ -130,13 +121,13 @@
<div *ngIf="competency">
<h2 class="ion-margin-horizontal">{{ 'addon.competency.evidence' | translate }}</h2>
<p class="ion-margin-horizontal" *ngIf="competency.evidence.length == 0">
<p class="ion-margin-horizontal" *ngIf="competency.evidence.length === 0">
{{ 'addon.competency.noevidence' | translate }}
</p>
<ion-card *ngFor="let evidence of competency.evidence">
<ion-item class="ion-text-wrap" *ngIf="evidence.actionuser" core-user-link [userId]="evidence.actionuser.id"
[courseId]="courseId">
<core-user-avatar [user]="evidence.actionuser" slot="start" [linkProfile]="false"></core-user-avatar>
<core-user-avatar [user]="evidence.actionuser" slot="start" [linkProfile]="false" />
<ion-label>
<p class="item-heading">{{ evidence.actionuser.fullname }}</p>
<p>{{ evidence.timemodified * 1000 | coreFormatDate }}</p>

View File

@ -36,7 +36,7 @@ import { ADDON_COMPETENCY_SUMMARY_PAGE } from '@addons/competency/competency.mod
import { CoreSwipeNavigationItemsManager } from '@classes/items-management/swipe-navigation-items-manager';
import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker';
import { AddonCompetencyPlanCompetenciesSource } from '@addons/competency/classes/competency-plan-competencies-source';
import { ActivatedRouteSnapshot } from '@angular/router';
import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router';
import { AddonCompetencyCourseCompetenciesSource } from '@addons/competency/classes/competency-course-competencies-source';
import { CoreTime } from '@singletons/time';
import { CoreAnalytics, CoreAnalyticsEventType } from '@services/analytics';
@ -350,8 +350,10 @@ class AddonCompetencyCompetenciesSwipeManager
/**
* @inheritdoc
*/
protected getSelectedItemPathFromRoute(route: ActivatedRouteSnapshot): string | null {
return route.params.competencyId;
protected getSelectedItemPathFromRoute(route: ActivatedRouteSnapshot | ActivatedRoute): string | null {
const snapshot = route instanceof ActivatedRouteSnapshot ? route : route.snapshot;
return snapshot.params.competencyId;
}
}

View File

@ -1,28 +1,26 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate"></ion-back-button>
<ion-back-button [text]="'core.back' | translate" />
</ion-buttons>
<ion-title>
<h1 *ngIf="competency">
<core-format-text [text]="competency.competency.shortname" [contextLevel]="contextLevel"
[contextInstanceId]="contextInstanceId">
</core-format-text> <small>{{ competency.competency.idnumber }}</small>
[contextInstanceId]="contextInstanceId" /> <small>{{ competency.competency.idnumber }}</small>
</h1>
</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="limited-width">
<ion-refresher slot="fixed" [disabled]="!competencyLoaded" (ionRefresh)="refreshCompetency($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}" />
</ion-refresher>
<core-loading [hideUntil]="competencyLoaded">
<ion-card *ngIf="competency">
<ion-item class="ion-text-wrap" *ngIf="competency.competency.description">
<ion-label>
<core-format-text [text]="competency.competency.description" [contextLevel]="contextLevel"
[contextInstanceId]="contextInstanceId">
</core-format-text>
[contextInstanceId]="contextInstanceId" />
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap">
@ -30,14 +28,12 @@
<p class="item-heading">{{ 'addon.competency.path' | translate }}</p>
<p>
<core-format-text [text]="competency.comppath.framework.name" [contextLevel]="contextLevel"
[contextInstanceId]="contextInstanceId">
</core-format-text>
[contextInstanceId]="contextInstanceId" />
<ng-container *ngFor="let ancestor of competency.comppath.ancestors">
&nbsp;/&nbsp;
<button class="as-link" (click)="openCompetencySummary(ancestor.id)">
<core-format-text [text]="ancestor.name" [contextLevel]="contextLevel"
[contextInstanceId]="contextInstanceId">
</core-format-text>
[contextInstanceId]="contextInstanceId" />
</button>
</ng-container>
</p>

View File

@ -1,7 +1,7 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate"></ion-back-button>
<ion-back-button [text]="'core.back' | translate" />
</ion-buttons>
<ion-title>
<h1>{{ 'addon.competency.coursecompetencies' | translate }}</h1>
@ -10,7 +10,7 @@
</ion-header>
<ion-content class="limited-width">
<ion-refresher slot="fixed" [disabled]="!competencies.loaded" (ionRefresh)="refreshCourseCompetencies($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}" />
</ion-refresher>
<core-loading [hideUntil]="competencies.loaded">
<ion-card *ngIf="!user && courseCompetencies && courseCompetencies.statistics.competencycount > 0">
@ -29,8 +29,7 @@
{x: courseCompetencies.statistics.proficientcompetencycount, y: courseCompetencies.statistics.competencycount} } }}
</span>
<core-progress-bar [progress]="courseCompetencies.statistics.proficientcompetencypercentage"
ariaDescribedBy="addon-competency-course-{{courseId}}-progress">
</core-progress-bar>
ariaDescribedBy="addon-competency-course-{{courseId}}-progress" />
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap"
@ -39,8 +38,8 @@
<p class="item-heading">{{ 'addon.competency.competenciesmostoftennotproficientincourse' | translate }}</p>
<p *ngFor="let comp of courseCompetencies.statistics.leastproficient">
<button class="as-link" (click)="openCompetencySummary(comp.id)">
<core-format-text [text]="comp.shortname" contextLevel="course" [contextInstanceId]="courseId">
</core-format-text> - {{ comp.idnumber }}
<core-format-text [text]="comp.shortname" contextLevel="course" [contextInstanceId]="courseId" /> - {{
comp.idnumber }}
</button>
</p>
</ion-label>
@ -52,15 +51,14 @@
</h2>
<ion-card *ngIf="user">
<ion-item class="ion-text-wrap">
<core-user-avatar [user]="user" slot="start"></core-user-avatar>
<core-user-avatar [user]="user" slot="start" />
<ion-label>
<p class="item-heading">{{ user.fullname }}</p>
</ion-label>
</ion-item>
</ion-card>
<core-empty-box *ngIf="courseCompetencies && courseCompetencies.statistics.competencycount == 0" icon="fas-award"
message="{{ 'addon.competency.nocompetenciesincourse' | translate }}">
</core-empty-box>
<core-empty-box *ngIf="courseCompetencies && courseCompetencies.statistics.competencycount === 0" icon="fas-award"
message="{{ 'addon.competency.nocompetenciesincourse' | translate }}" />
<div *ngIf="competencies.loaded">
<ion-card *ngFor="let competency of competencies.items">
@ -68,8 +66,8 @@
[attr.aria-label]="competency.competency.shortname" [detail]="true" button>
<ion-label>
<p class="item-heading">
<core-format-text [text]="competency.competency.shortname" contextLevel="course" [contextInstanceId]="courseId">
</core-format-text> <em>{{competency.competency.idnumber}}</em>
<core-format-text [text]="competency.competency.shortname" contextLevel="course"
[contextInstanceId]="courseId" /> <em>{{competency.competency.idnumber}}</em>
</p>
</ion-label>
<ion-badge slot="end" *ngIf="competency.usercompetencycourse && competency.usercompetencycourse.gradename"
@ -81,8 +79,7 @@
<ion-label>
<p *ngIf="competency.competency.description">
<core-format-text [text]="competency.competency.description" contextLevel="course"
[contextInstanceId]="courseId">
</core-format-text>
[contextInstanceId]="courseId" />
</p>
<div>
<p class="item-heading">{{ 'addon.competency.path' | translate }}</p>
@ -90,24 +87,20 @@
<a *ngIf="competency.comppath.showlinks" [href]="getCompetencyFrameworkUrl(competency)" core-link
[title]="competency.comppath.framework.name">
<core-format-text [text]="competency.comppath.framework.name" contextLevel="course"
[contextInstanceId]="courseId">
</core-format-text>
[contextInstanceId]="courseId" />
</a>
<ng-container *ngIf="!competency.comppath.showlinks">
<core-format-text [text]="competency.comppath.framework.name" contextLevel="course"
[contextInstanceId]="courseId">
</core-format-text>
[contextInstanceId]="courseId" />
</ng-container>
&nbsp;/&nbsp;
<ng-container *ngFor="let ancestor of competency.comppath.ancestors">
<button class="as-link" *ngIf="competency.comppath.showlinks"
(click)="openCompetencySummary(ancestor.id)">
<core-format-text [text]="ancestor.name" contextLevel="course" [contextInstanceId]="courseId">
</core-format-text>
<core-format-text [text]="ancestor.name" contextLevel="course" [contextInstanceId]="courseId" />
</button>
<ng-container *ngIf="!competency.comppath.showlinks">
<core-format-text [text]="ancestor.name" contextLevel="course" [contextInstanceId]="courseId">
</core-format-text>
<core-format-text [text]="ancestor.name" contextLevel="course" [contextInstanceId]="courseId" />
</ng-container>
<ng-container *ngIf="!ancestor.last">&nbsp;/&nbsp;</ng-container>
</ng-container>
@ -121,30 +114,27 @@
</div>
<div>
<p class="item-heading">{{ 'addon.competency.activities' | translate }}</p>
<p *ngIf="competency.coursemodules.length == 0">
<p *ngIf="competency.coursemodules.length === 0">
{{ 'addon.competency.noactivities' | translate }}
</p>
<ion-item class="ion-text-wrap core-course-module-handler" [attr.aria-label]="activity.name" core-link
*ngFor="let activity of competency.coursemodules" [href]="activity.url" capture="true">
<core-mod-icon slot="start" [modicon]="activity.iconurl" [showAlt]="false" *ngIf="activity.iconurl">
</core-mod-icon>
<core-mod-icon slot="start" [modicon]="activity.iconurl" [showAlt]="false" *ngIf="activity.iconurl" />
<ion-label>
<core-format-text [text]="activity.name" contextLevel="module" [contextInstanceId]="activity.id"
[courseId]="courseId">
</core-format-text>
[courseId]="courseId" />
</ion-label>
</ion-item>
</div>
<div *ngIf="competency.plans">
<p class="item-heading">{{ 'addon.competency.userplans' | translate }}</p>
<p *ngIf="competency.plans.length == 0">
<p *ngIf="competency.plans.length === 0">
{{ 'addon.competency.nouserplanswithcompetency' | translate }}
</p>
<ion-item class="ion-text-wrap" *ngFor="let plan of competency.plans" [href]="plan.url"
[attr.aria-label]="plan.name" core-link capture="true">
<ion-label>
<core-format-text [text]="plan.name" contextLevel="user" [contextInstanceId]="plan.userid">
</core-format-text>
<core-format-text [text]="plan.name" contextLevel="user" [contextInstanceId]="plan.userid" />
</ion-label>
</ion-item>
</div>

View File

@ -1,25 +1,24 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate"></ion-back-button>
<ion-back-button [text]="'core.back' | translate" />
</ion-buttons>
<ion-title>
<h1 *ngIf="plan">
<core-format-text [text]="plan.plan.name" contextLevel="user" [contextInstanceId]="plan.plan.userid">
</core-format-text>
<core-format-text [text]="plan.plan.name" contextLevel="user" [contextInstanceId]="plan.plan.userid" />
</h1>
</ion-title>
</ion-toolbar>
</ion-header>
<ion-content [core-swipe-navigation]="plans" class="limited-width">
<ion-refresher slot="fixed" [disabled]="!competencies.loaded" (ionRefresh)="refreshLearningPlan($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}" />
</ion-refresher>
<core-loading [hideUntil]="competencies.loaded">
<ion-card *ngIf="user">
<ion-item class="ion-text-wrap">
<ion-label>
<core-user-avatar [user]="user" slot="start"></core-user-avatar>
<core-user-avatar [user]="user" slot="start" />
<p class="item-heading">{{ user.fullname }}</p>
</ion-label>
</ion-item>
@ -29,8 +28,7 @@
<ion-item class="ion-text-wrap" *ngIf="plan.plan.description">
<ion-label>
<p>
<core-format-text [text]="plan.plan.description" contextLevel="user" [contextInstanceId]="plan.plan.userid">
</core-format-text>
<core-format-text [text]="plan.plan.description" contextLevel="user" [contextInstanceId]="plan.plan.userid" />
</p>
</ion-label>
</ion-item>
@ -50,8 +48,7 @@
<ion-label>
<p class="item-heading">{{ 'addon.competency.template' | translate }}</p>
<p>
<core-format-text [text]="plan.plan.template.shortname" contextLevel="system" [contextInstanceId]="0">
</core-format-text>
<core-format-text [text]="plan.plan.template.shortname" contextLevel="system" [contextInstanceId]="0" />
</p>
</ion-label>
</ion-item>
@ -64,8 +61,7 @@
</p>
<core-progress-bar [progress]="plan.proficientcompetencypercentage"
[text]="plan.proficientcompetencypercentageformatted"
ariaDescribedBy="addon-competency-plan-{{plan.plan.id}}-progress">
</core-progress-bar>
ariaDescribedBy="addon-competency-plan-{{plan.plan.id}}-progress" />
</ion-label>
</ion-item>
</ion-list>
@ -75,7 +71,7 @@
<ion-card-title>{{ 'addon.competency.learningplancompetencies' | translate }}</ion-card-title>
</ion-card-header>
<ion-list>
<ion-item class="ion-text-wrap" *ngIf="plan.competencycount == 0">
<ion-item class="ion-text-wrap" *ngIf="plan.competencycount === 0">
<ion-label>
<p>{{ 'addon.competency.nocompetencies' | translate }}</p>
</ion-label>
@ -85,8 +81,7 @@
<ion-label>
<p class="item-heading">
<core-format-text [text]="competency.competency.shortname" contextLevel="user"
[contextInstanceId]="plan.plan.userid">
</core-format-text> <em>{{competency.competency.idnumber}}</em>
[contextInstanceId]="plan.plan.userid" /> <em>{{competency.competency.idnumber}}</em>
</p>
</ion-label>
<ion-badge *ngIf="competency.usercompetencyplan" slot="end"

View File

@ -1,7 +1,7 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate"></ion-back-button>
<ion-back-button [text]="'core.back' | translate" />
</ion-buttons>
<ion-title>
<h1>{{ 'addon.competency.userplans' | translate }}</h1>
@ -11,19 +11,16 @@
<ion-content>
<core-split-view>
<ion-refresher slot="fixed" [disabled]="!plans.loaded" (ionRefresh)="refreshLearningPlans($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}" />
</ion-refresher>
<core-loading [hideUntil]="plans.loaded">
<core-empty-box *ngIf="plans.empty" icon="fas-route" [message]="'addon.competency.noplanswerecreated' | translate">
</core-empty-box>
<core-empty-box *ngIf="plans.empty" icon="fas-route" [message]="'addon.competency.noplanswerecreated' | translate" />
<ion-list *ngIf="!plans.empty" class="ion-no-margin">
<ion-item class="ion-text-wrap" *ngFor="let plan of plans.items" [attr.aria-label]="plan.name" (click)="plans.select(plan)"
[attr.aria-current]="plans.getItemAriaCurrent(plan)" button [detail]="true">
<ion-label>
<p class="item-heading">
<core-format-text [text]="plan.name" contextLevel="user" [contextInstanceId]="plan.userid">
</core-format-text>
<core-format-text [text]="plan.name" contextLevel="user" [contextInstanceId]="plan.userid" />
</p>
<p *ngIf="plan.duedate > 0">
{{ 'addon.competency.duedate' | translate }}:&nbsp;

View File

@ -389,7 +389,7 @@ Feature: Test competency navigation
# Participant competencies
When I press "Participants" in the app
And I press "Student first" in the app
And I press "Competencies" in the app
And I press "Competencies" within "Student first" "page-core-user-participants" in the app
Then I should find "Student first" in the app
And I should find "Salads are important" in the app
And I should find "Good" within "salads" "ion-item" in the app

View File

@ -1,7 +1,7 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate"></ion-back-button>
<ion-back-button [text]="'core.back' | translate" />
</ion-buttons>
<ion-title>
<h1>{{ 'addon.coursecompletion.coursecompletion' | translate }}</h1>
@ -10,11 +10,11 @@
</ion-header>
<ion-content>
<ion-refresher slot="fixed" [disabled]="!completionLoaded" (ionRefresh)="refreshCompletion($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}" />
</ion-refresher>
<core-loading [hideUntil]="completionLoaded">
<ion-item class="ion-text-wrap" *ngIf="user">
<core-user-avatar [user]="user" [courseId]="courseId" slot="start" [linkProfile]="false"></core-user-avatar>
<core-user-avatar [user]="user" [courseId]="courseId" slot="start" [linkProfile]="false" />
<ion-label>
<p class="item-heading">{{user.fullname}}</p>
</ion-label>
@ -44,10 +44,10 @@
<ion-item class="ion-hide-md-up ion-text-wrap" *ngFor="let criteria of completion.completions">
<ion-label>
<p class="item-heading">
<core-format-text clean="true" [text]="criteria.details.criteria" [filter]="false"></core-format-text>
<core-format-text clean="true" [text]="criteria.details.criteria" [filter]="false" />
</p>
<p>
<core-format-text clean="true" [text]="criteria.details.requirement" [filter]="false"></core-format-text>
<core-format-text clean="true" [text]="criteria.details.requirement" [filter]="false" />
</p>
</ion-label>
<strong slot="end" *ngIf="criteria.complete">{{ 'core.yes' | translate }}</strong>
@ -65,23 +65,23 @@
</ion-row>
<ion-row *ngFor="let criteria of completion.completions">
<ion-col>
<core-format-text clean="true" [text]="criteria.details.type" [filter]="false"></core-format-text>
<core-format-text clean="true" [text]="criteria.details.type" [filter]="false" />
</ion-col>
<ion-col>
<core-format-text clean="true" [text]="criteria.details.criteria" [filter]="false"></core-format-text>
<core-format-text clean="true" [text]="criteria.details.criteria" [filter]="false" />
</ion-col>
<ion-col>
<core-format-text clean="true" [text]="criteria.details.requirement" [filter]="false"></core-format-text>
<core-format-text clean="true" [text]="criteria.details.requirement" [filter]="false" />
</ion-col>
<ion-col>
<core-format-text [text]="criteria.details.status" [filter]="false"></core-format-text>
<core-format-text [text]="criteria.details.status" [filter]="false" />
</ion-col>
<ion-col *ngIf="criteria.complete">{{ 'core.yes' | translate }}</ion-col>
<ion-col *ngIf="!criteria.complete">{{ 'core.no' | translate }}</ion-col>
<ion-col *ngIf="criteria.timecompleted">
{{ criteria.timecompleted * 1000 | coreFormatDate :'strftimedatetimeshort' }}
</ion-col>
<ion-col *ngIf="!criteria.timecompleted"></ion-col>
<ion-col *ngIf="!criteria.timecompleted" />
</ion-row>
</ion-label>
</ion-item>
@ -103,7 +103,7 @@
<ion-card class="core-warning-card" *ngIf="!tracked">
<ion-item>
<ion-icon name="fas-triangle-exclamation" slot="start" aria-hidden="true"></ion-icon>
<ion-icon name="fas-triangle-exclamation" slot="start" aria-hidden="true" />
<ion-label>{{ 'addon.coursecompletion.nottracked' | translate }}</ion-label>
</ion-item>
</ion-card>

View File

@ -21,9 +21,10 @@ import { CoreSite } from '@classes/sites/site';
import { CoreStatusWithWarningsWSResponse, CoreWSExternalWarning } from '@services/ws';
import { makeSingleton } from '@singletons';
import { CoreError } from '@classes/errors/error';
import { asyncObservable, firstValueFrom } from '@/core/utils/rxjs';
import { asyncObservable } from '@/core/utils/rxjs';
import { map } from 'rxjs/operators';
import { CoreSiteWSPreSets, WSObservable } from '@classes/sites/authenticated-site';
import { firstValueFrom } from 'rxjs';
const ROOT_CACHE_KEY = 'mmaCourseCompletion:';

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable, ViewContainerRef, ComponentFactoryResolver } from '@angular/core';
import { Injectable, ViewContainerRef } from '@angular/core';
import { CoreFilterDefaultHandler } from '@features/filter/services/handlers/default-filter';
import { CoreFilterFilter, CoreFilterFormatTextOptions } from '@features/filter/services/filter';
@ -32,10 +32,6 @@ export class AddonFilterDisplayH5PHandlerService extends CoreFilterDefaultHandle
protected template = document.createElement('template'); // A template element to convert HTML to element.
constructor(protected factoryResolver: ComponentFactoryResolver) {
super();
}
/**
* @inheritdoc
*/
@ -95,8 +91,7 @@ export class AddonFilterDisplayH5PHandlerService extends CoreFilterDefaultHandle
const url = placeholder.getAttribute('data-player-src') || '';
// Create the component to display the player.
const factory = this.factoryResolver.resolveComponentFactory(CoreH5PPlayerComponent);
const componentRef = viewContainerRef.createComponent<CoreH5PPlayerComponent>(factory);
const componentRef = viewContainerRef.createComponent<CoreH5PPlayerComponent>(CoreH5PPlayerComponent);
componentRef.instance.src = url;
componentRef.instance.component = component;

View File

@ -411,7 +411,7 @@ type MathJaxWindow = Window & {
_configured: boolean; // eslint-disable-line @typescript-eslint/naming-convention
// Add the configuration to the head and set the lang.
configure: (params: Record<string, unknown>) => void;
_setLocale: () => void; // eslint-disable-line @typescript-eslint/naming-convention
_setLocale: () => void;
typeset: (container: HTMLElement) => void;
};
};

View File

@ -147,7 +147,7 @@ export class VideoJSOgvJS extends Tech {
if (el.hasOwnProperty(name)) {
el[name] = value;
}
};
}
/**
* Check if browser/device is supported by Ogv.JS.
@ -156,7 +156,7 @@ export class VideoJSOgvJS extends Tech {
*/
static isSupported(): boolean {
return OGVCompat.supported('OGVPlayer');
};
}
/**
* Check if the tech can support the given type.
@ -166,7 +166,7 @@ export class VideoJSOgvJS extends Tech {
*/
static canPlayType(type: string): string {
return (type.indexOf('/ogg') !== -1 || type.indexOf('/webm')) ? 'maybe' : '';
};
}
/**
* Check if the tech can support the given source.
@ -176,7 +176,7 @@ export class VideoJSOgvJS extends Tech {
*/
static canPlaySource(srcObj: TechSourceObject): string {
return VideoJSOgvJS.canPlayType(srcObj.type);
};
}
/**
* Check if the volume can be changed in this browser/device.
@ -194,7 +194,7 @@ export class VideoJSOgvJS extends Tech {
// eslint-disable-next-line no-prototype-builtins
return player.hasOwnProperty('volume');
};
}
/**
* Check if the volume can be muted in this browser/device.
@ -203,7 +203,7 @@ export class VideoJSOgvJS extends Tech {
*/
static canMuteVolume(): boolean {
return true;
};
}
/**
* Check if the playback rate can be changed in this browser/device.
@ -212,7 +212,7 @@ export class VideoJSOgvJS extends Tech {
*/
static canControlPlaybackRate(): boolean {
return true;
};
}
/**
* Check to see if native 'TextTracks' are supported by this browser/device.
@ -221,7 +221,7 @@ export class VideoJSOgvJS extends Tech {
*/
static supportsNativeTextTracks(): boolean {
return false;
};
}
/**
* Check if the fullscreen resize is supported by this browser/device.
@ -230,7 +230,7 @@ export class VideoJSOgvJS extends Tech {
*/
static supportsFullscreenResize(): boolean {
return true;
};
}
/**
* Check if the progress events is supported by this browser/device.
@ -239,7 +239,7 @@ export class VideoJSOgvJS extends Tech {
*/
static supportsProgressEvents(): boolean {
return true;
};
}
/**
* Check if the time update events is supported by this browser/device.
@ -248,7 +248,7 @@ export class VideoJSOgvJS extends Tech {
*/
static supportsTimeupdateEvents(): boolean {
return true;
};
}
/**
* Create the 'OgvJS' Tech's DOM element.

View File

@ -1,7 +1,7 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate"></ion-back-button>
<ion-back-button [text]="'core.back' | translate" />
</ion-buttons>
<ion-title>
<h1>{{ 'addon.messageoutput_airnotifier.processorsettingsdesc' | translate }}</h1>
@ -10,7 +10,7 @@
</ion-header>
<ion-content>
<ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="refreshDevices($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}" />
</ion-refresher>
<core-loading [hideUntil]="loaded">
<ng-container *ngFor="let platform of platformDevices">
@ -23,7 +23,7 @@
<ion-list>
<ion-item class="ion-text-wrap" *ngFor="let device of platform.devices" [class.item-current]="device.current">
<ion-label>
<p class="item-heading">
<p class="item-heading" id="device-{{device.id}}">
<strong>{{ device.name }} {{ device.model }}</strong> ({{platform.platform}} {{ device.version }})
</p>
<p *ngIf="device.current"><strong>{{ 'core.currentdevice' | translate }}</strong></p>
@ -33,8 +33,8 @@
</p>
</ion-label>
<core-button-with-spinner [loading]="device.updating" slot="end">
<ion-toggle [(ngModel)]="device.enable" (ngModelChange)="enableDevice(device, device.enable)">
</ion-toggle>
<ion-toggle [(ngModel)]="device.enable" (ngModelChange)="enableDevice(device, device.enable)"
[attr.aria-labelledby]="'device-'+ device.id " />
</core-button-with-spinner>
</ion-item>
</ion-list>

View File

@ -5,14 +5,14 @@
</ion-title>
<ion-buttons slot="end">
<ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
<ion-icon name="fas-xmark" slot="icon-only" aria-hidden="true"></ion-icon>
<ion-icon name="fas-xmark" slot="icon-only" aria-hidden="true" />
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="refreshData($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}" />
</ion-refresher>
<core-loading [hideUntil]="loaded">
@ -23,12 +23,11 @@
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" />
</h2>
<p>
<core-format-text *ngIf="conversation.subname" [text]="conversation.subname" contextLevel="system"
[contextInstanceId]="0">
</core-format-text>
[contextInstanceId]="0" />
</p>
<p>{{ 'addon.messages.numparticipants' | translate:{$a: conversation.membercount} }}</p>
</ion-label>
@ -36,19 +35,16 @@
<ion-item class="ion-text-wrap addon-messages-conversation-item" *ngFor="let member of members" (click)="closeModal(member.id)"
[detail]="true" button>
<core-user-avatar [user]="member" [linkProfile]="false" [checkOnline]="member.showonlinestatus" slot="start">
</core-user-avatar>
<core-user-avatar [user]="member" [linkProfile]="false" [checkOnline]="member.showonlinestatus" slot="start" />
<ion-label>
<p class="item-heading">
{{ member.fullname }}
<ion-icon name="fas-user-slash" *ngIf="member.isblocked"
[attr.aria-label]="'addon.messages.contactblocked' | translate">
</ion-icon>
[attr.aria-label]="'addon.messages.contactblocked' | translate" />
</p>
</ion-label>
</ion-item>
<core-infinite-loading [enabled]="canLoadMore" (action)="loadMoreMembers($event)" [error]="loadMoreError">
</core-infinite-loading>
<core-infinite-loading [enabled]="canLoadMore" (action)="loadMoreMembers($event)" [error]="loadMoreError" />
</core-loading>
</ion-content>

View File

@ -12,39 +12,23 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, UrlTree } from '@angular/router';
import { ActivatedRouteSnapshot, CanActivateFn } from '@angular/router';
import { Router } from '@singletons';
import { AddonMessagesMainMenuHandlerService } from '../services/handlers/mainmenu';
import { AddonMessages } from '../services/messages';
/**
* Guard to redirect to the right page based on the current Moodle site version.
*
* @returns Route.
*/
@Injectable({ providedIn: 'root' })
export class AddonMessagesIndexGuard implements CanActivate {
export const messagesIndexGuard: CanActivateFn = async (route: ActivatedRouteSnapshot) => {
const enabled = AddonMessages.isGroupMessagingEnabled();
const path = `/main/${AddonMessagesMainMenuHandlerService.PAGE_NAME}/` + ( enabled ? 'group-conversations' : 'index');
/**
* @inheritdoc
*/
canActivate(route: ActivatedRouteSnapshot): UrlTree {
return this.guard(route);
}
const newRoute = Router.parseUrl(path);
/**
* Check if there is a pending redirect and trigger it.
*
* @returns The redirection route.
*/
private guard(route: ActivatedRouteSnapshot): UrlTree {
const enabled = AddonMessages.isGroupMessagingEnabled();
const path = `/main/${AddonMessagesMainMenuHandlerService.PAGE_NAME}/` + ( enabled ? 'group-conversations' : 'index');
newRoute.queryParams = route.queryParams;
const newRoute = Router.parseUrl(path);
newRoute.queryParams = route.queryParams;
return newRoute;
}
}
return newRoute;
};

View File

@ -28,7 +28,7 @@ import { CoreMainMenuComponentsModule } from '@features/mainmenu/components/comp
import { buildTabMainRoutes } from '@features/mainmenu/mainmenu-tab-routing.module';
import { CoreSearchComponentsModule } from '@features/search/components/components.module';
import { CoreScreen } from '@services/screen';
import { AddonMessagesIndexGuard } from './guards';
import { messagesIndexGuard } from './guards';
/**
* Build module routes.
@ -120,7 +120,7 @@ function buildRoutes(injector: Injector): Routes {
loadChildren: () => import('./messages-settings-lazy.module').then(m => m.AddonMessagesSettingsLazyModule),
},
...buildTabMainRoutes(injector, {
canActivate: [AddonMessagesIndexGuard],
canActivate: [messagesIndexGuard],
}),
];
}

View File

@ -1,32 +1,32 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate"></ion-back-button>
<ion-back-button [text]="'core.back' | translate" />
</ion-buttons>
<ion-title>
<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>
<core-context-menu />
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<core-split-view>
<ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="refreshData($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}" />
</ion-refresher>
<core-search-box (onSubmit)="search($event)" (onClear)="clearSearch()" [placeholder]="'addon.messages.contactname' | translate"
autocorrect="off" spellcheck="false" lengthCheck="2" [disabled]="!loaded" searchArea="AddonMessagesContacts"></core-search-box>
autocorrect="off" spellcheck="false" lengthCheck="2" [disabled]="!loaded" searchArea="AddonMessagesContacts" />
<core-loading [hideUntil]="loaded" [message]="loadingMessage">
<core-empty-box *ngIf="!hasContacts && searchString === ''" icon="fas-address-book"
[message]="'addon.messages.contactlistempty' | translate"></core-empty-box>
[message]="'addon.messages.contactlistempty' | translate" />
<core-empty-box *ngIf="!hasContacts && searchString !== ''" icon="fas-address-book"
[message]="'addon.messages.nousersfound' | translate"></core-empty-box>
[message]="'addon.messages.nousersfound' | translate" />
<ion-list *ngFor="let contactType of contactTypes" class="ion-no-margin">
<ng-container *ngIf="contacts[contactType] && (contacts[contactType].length > 0 || contactType === searchType)">
@ -43,8 +43,8 @@
<ion-item class="ion-text-wrap addon-messages-conversation-item"
*ngIf="contact.profileimageurl || contact.profileimageurlsmall" [attr.aria-label]="contact.fullname"
(click)="gotoDiscussion(contact.id)" [detail]="true" button
[attr.aria-current]="contact.id == discussionUserId ? 'page' : 'false'">
<core-user-avatar [user]="contact" slot="start" [checkOnline]="contact.showonlinestatus"></core-user-avatar>
[attr.aria-current]="contact.id === discussionUserId ? 'page' : 'false'">
<core-user-avatar [user]="contact" slot="start" [checkOnline]="contact.showonlinestatus" />
<ion-label>
<p class="item-heading">{{ contact.fullname }}</p>
</ion-label>

View File

@ -1,17 +1,17 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate"></ion-back-button>
<ion-back-button [text]="'core.back' | translate" />
</ion-buttons>
<ion-title>
<h1>{{ 'addon.messages.contacts' | translate }}</h1>
</ion-title>
<ion-buttons slot="end">
<ion-button fill="clear" (click)="gotoSearch()" [attr.aria-label]="'addon.messages.searchcombined' | translate">
<ion-icon name="fas-magnifying-glass" slot="icon-only" aria-hidden="true"></ion-icon>
<ion-icon name="fas-magnifying-glass" slot="icon-only" aria-hidden="true" />
</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-context-menu />
</ion-buttons>
</ion-toolbar>
</ion-header>
@ -23,35 +23,30 @@
<core-tab [title]="'addon.messages.contacts' | translate" (ionSelect)="selectTab('confirmed')">
<ng-template>
<ion-refresher slot="fixed" [disabled]="!confirmedLoaded" (ionRefresh)="refreshData($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}" />
</ion-refresher>
<core-loading [hideUntil]="confirmedLoaded">
<ion-list class="ion-no-margin" *ngIf="confirmedContacts.length">
<ion-item class="ion-text-wrap addon-messages-conversation-item" (click)="selectUser(contact.id)" button
*ngFor="let contact of confirmedContacts" [attr.aria-label]="contact.fullname" [detail]="true"
[attr.aria-current]="contact.id == selectedUserId ? 'page' : 'false'">
[attr.aria-current]="contact.id === selectedUserId ? 'page' : 'false'">
<core-user-avatar slot="start" [user]="contact" [checkOnline]="contact.showonlinestatus"
[linkProfile]="false">
</core-user-avatar>
[linkProfile]="false" />
<ion-label>
<p class="item-heading">
<core-format-text [text]="contact.fullname" contextLevel="system" [contextInstanceId]="0">
</core-format-text>
<core-format-text [text]="contact.fullname" contextLevel="system" [contextInstanceId]="0" />
<ion-icon *ngIf="contact.isblocked" name="fas-user-slash" slot="end"
[attr.aria-label]="'addon.messages.contactblocked' | translate">
</ion-icon>
[attr.aria-label]="'addon.messages.contactblocked' | translate" />
</p>
</ion-label>
</ion-item>
</ion-list>
<core-empty-box *ngIf="!confirmedContacts.length" icon="far-address-book"
[message]="'addon.messages.nocontactsgetstarted' | translate">
</core-empty-box>
[message]="'addon.messages.nocontactsgetstarted' | translate" />
<core-infinite-loading [enabled]="confirmedCanLoadMore" (action)="loadMore($event)" [error]="confirmedLoadMoreError"
position="bottom">
</core-infinite-loading>
position="bottom" />
</core-loading>
</ng-template>
</core-tab>
@ -61,17 +56,16 @@
badgeA11yText="addon.messages.pendingcontactrequests">
<ng-template>
<ion-refresher slot="fixed" [disabled]="!requestsLoaded" (ionRefresh)="refreshData($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}" />
</ion-refresher>
<core-loading [hideUntil]="requestsLoaded">
<ion-list class="ion-no-margin" *ngIf="requests.length">
<ion-item class="ion-text-wrap addon-messages-conversation-item" *ngFor="let request of requests"
[attr.aria-label]="request.fullname" (click)="selectUser(request.id)" button
[attr.aria-current]="request.id == selectedUserId ? 'page' : 'false'" [detail]="true">
<core-user-avatar slot="start" [user]="request" [linkProfile]="false"></core-user-avatar>
[attr.aria-current]="request.id === selectedUserId ? 'page' : 'false'" [detail]="true">
<core-user-avatar slot="start" [user]="request" [linkProfile]="false" />
<ion-label>
<core-format-text [text]="request.fullname" contextLevel="system" [contextInstanceId]="0">
</core-format-text>
<core-format-text [text]="request.fullname" contextLevel="system" [contextInstanceId]="0" />
<p *ngIf="!request.iscontact">
{{ 'addon.messages.wouldliketocontactyou' | translate }}
</p>
@ -79,11 +73,9 @@
</ion-item>
</ion-list>
<core-empty-box *ngIf="!requests.length" icon="far-address-book"
[message]="'addon.messages.nocontactrequests' | translate">
</core-empty-box>
[message]="'addon.messages.nocontactrequests' | translate" />
<core-infinite-loading [enabled]="requestsCanLoadMore" (action)="loadMore($event)" [error]="requestsLoadMoreError"
position="bottom">
</core-infinite-loading>
position="bottom" />
</core-loading>
</ng-template>
</core-tab>

View File

@ -1,67 +1,57 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate"></ion-back-button>
<ion-back-button [text]="'core.back' | translate" />
</ion-buttons>
<ion-title>
<h1>
<img *ngIf="loaded && !otherMember && conversationImage" class="core-bar-button-image" [src]="conversationImage" alt=""
onError="this.src='assets/img/group-avatar.svg'" core-external-content role="presentation" [siteId]="siteId">
<core-user-avatar *ngIf="loaded && otherMember" class="core-bar-button-image" [user]="otherMember" [linkProfile]="false"
[checkOnline]="otherMember.showonlinestatus">
</core-user-avatar>
<core-format-text [text]="title" contextLevel="system" [contextInstanceId]="0"></core-format-text>
[checkOnline]="otherMember.showonlinestatus" />
<core-format-text [text]="title" contextLevel="system" [contextInstanceId]="0" />
<ion-icon *ngIf="conversation && conversation.isfavourite" name="fas-star"
[attr.aria-label]="'core.favourites' | translate">
</ion-icon>
[attr.aria-label]="'core.favourites' | translate" />
<ion-icon *ngIf="conversation && conversation.ismuted" name="fas-bell-slash"
[attr.aria-label]="'addon.messages.mutedconversation' | translate">
</ion-icon>
[attr.aria-label]="'addon.messages.mutedconversation' | translate" />
</h1>
</ion-title>
<ion-buttons slot="end"></ion-buttons>
<ion-buttons slot="end" />
</ion-toolbar>
<core-navbar-buttons slot="end">
<core-context-menu [attr.aria-label]="'addon.messages.conversationactions' | translate">
<core-context-menu-item [hidden]="isSelf || !showInfo || isGroup" [priority]="1000"
[content]="'addon.messages.info' | translate" (action)="viewInfo()" iconAction="fas-circle-info"></core-context-menu-item>
[content]="'addon.messages.info' | translate" (action)="viewInfo()" iconAction="fas-circle-info" />
<core-context-menu-item [hidden]="isSelf || !showInfo || !isGroup" [priority]="1000"
[content]="'addon.messages.groupinfo' | translate" (action)="viewInfo()" iconAction="fas-circle-info">
</core-context-menu-item>
[content]="'addon.messages.groupinfo' | translate" (action)="viewInfo()" iconAction="fas-circle-info" />
<core-context-menu-item [hidden]="!groupMessagingEnabled || !conversation" [priority]="800" (action)="changeFavourite($event)"
[closeOnClick]="false" [content]="(conversation && conversation.isfavourite ? 'addon.messages.removefromfavourites' :
'addon.messages.addtofavourites') | translate" [iconAction]="favouriteIcon" [iconSlash]="favouriteIconSlash">
</core-context-menu-item>
'addon.messages.addtofavourites') | translate" [iconAction]="favouriteIcon" [iconSlash]="favouriteIconSlash" />
<core-context-menu-item [hidden]="isSelf || !otherMember || otherMember.isblocked" [priority]="700"
[content]="'addon.messages.blockuser' | translate" (action)="blockUser()" [iconAction]="blockIcon">
</core-context-menu-item>
[content]="'addon.messages.blockuser' | translate" (action)="blockUser()" [iconAction]="blockIcon" />
<core-context-menu-item [hidden]="isSelf || !otherMember || !otherMember.isblocked" [priority]="700"
[content]="'addon.messages.unblockuser' | translate" (action)="unblockUser()" [iconAction]="blockIcon">
</core-context-menu-item>
[content]="'addon.messages.unblockuser' | translate" (action)="unblockUser()" [iconAction]="blockIcon" />
<core-context-menu-item [hidden]="isSelf || !muteEnabled || !conversation" [priority]="600" (action)="changeMute($event)"
[closeOnClick]="false" [content]="(conversation && conversation.ismuted ? 'addon.messages.unmuteconversation' :
'addon.messages.muteconversation') | translate" [iconAction]="muteIcon"></core-context-menu-item>
'addon.messages.muteconversation') | translate" [iconAction]="muteIcon" />
<core-context-menu-item [hidden]="!canDelete || !messages || !messages.length" [priority]="400"
[content]="'addon.messages.showdeletemessages' | translate" iconAction="toggle" [(toggle)]="showDelete">
</core-context-menu-item>
[content]="'addon.messages.showdeletemessages' | translate" iconAction="toggle" [(toggle)]="showDelete" />
<core-context-menu-item [hidden]="!groupMessagingEnabled || !conversationId || isGroup || !messages || !messages.length"
[priority]="200" [content]="'addon.messages.deleteconversation' | translate" (action)="deleteConversation($event)"
[closeOnClick]="false" [iconAction]="deleteIcon"></core-context-menu-item>
[closeOnClick]="false" [iconAction]="deleteIcon" />
<core-context-menu-item
[hidden]="isSelf || !otherMember || otherMember.iscontact || requestContactSent || requestContactReceived" [priority]="100"
[content]="'addon.messages.addtoyourcontacts' | translate" (action)="createContactRequest()" [iconAction]="addRemoveIcon">
</core-context-menu-item>
[content]="'addon.messages.addtoyourcontacts' | translate" (action)="createContactRequest()" [iconAction]="addRemoveIcon" />
<core-context-menu-item [hidden]="isSelf || !otherMember || !otherMember.iscontact" [priority]="100"
[content]="'addon.messages.removefromyourcontacts' | translate" (action)="removeContact()" [iconAction]="addRemoveIcon"
[iconSlash]="true"></core-context-menu-item>
[iconSlash]="true" />
</core-context-menu>
</core-navbar-buttons>
</ion-header>
<ion-content (ionScroll)="scrollFunction()">
<core-loading [hideUntil]="loaded">
<!-- Load previous messages. -->
<core-infinite-loading [enabled]="canLoadMore" (action)="loadPrevious($event)" position="top" [error]="loadMoreError">
</core-infinite-loading>
<core-infinite-loading [enabled]="canLoadMore" (action)="loadPrevious($event)" position="top" [error]="loadMoreError" />
<ng-container *ngIf="isSelf && !canLoadMore">
<p class="ion-text-center">{{ 'addon.messages.selfconversation' | translate }}</p>
@ -76,27 +66,25 @@
{{ message.timecreated | coreFormatDate: "strftimedayshort" }}
</h3>
<ion-chip class="addon-messages-unreadfrom" *ngIf="unreadMessageFrom > 0 && message.id == unreadMessageFrom" color="light">
<ion-chip class="addon-messages-unreadfrom" *ngIf="unreadMessageFrom > 0 && message.id === unreadMessageFrom" color="light">
<ion-label>{{ 'addon.messages.newmessages' | translate }}</ion-label>
<ion-icon name="fas-arrow-down" aria-hidden="true"></ion-icon>
<ion-icon name="fas-arrow-down" aria-hidden="true" />
</ion-chip>
<core-message [message]="message" [user]="members[message.useridfrom]" (afterRender)="last && scrollToBottom()"
[text]="message.text" (onDeleteMessage)="deleteMessage(message, index)" [showDelete]="showDelete"
[time]="message.timecreated">
</core-message>
[time]="message.timecreated" />
</ng-container>
</ion-list>
<core-empty-box *ngIf="!messages || messages.length <= 0" icon="far-comments"
[message]="'addon.messages.nomessagesfound' | translate">
</core-empty-box>
[message]="'addon.messages.nomessagesfound' | translate" />
</core-loading>
<!-- Scroll bottom. -->
<ion-fab slot="fixed" core-fab vertical="bottom" horizontal="end" *ngIf="loaded && newMessages > 0">
<ion-fab-button size="small" (click)="scrollToFirstUnreadMessage()" color="light"
[attr.aria-label]="'addon.messages.newmessages' | translate">
<ion-icon name="fas-arrow-down" aria-hidden="true"></ion-icon>
<ion-icon name="fas-arrow-down" aria-hidden="true" />
<span class="sr-only">{{ 'addon.messages.newmessages' | translate }}</span>
</ion-fab-button>
<ion-badge class="core-discussion-messages-badge">{{ newMessages }}</ion-badge>
@ -138,6 +126,6 @@
</p>
</div>
<core-send-message-form *ngIf="footerType === 'message'" (onSubmit)="sendMessage($event)" [showKeyboard]="showKeyboard"
[placeholder]="'addon.messages.newmessage' | translate"></core-send-message-form>
[placeholder]="'addon.messages.newmessage' | translate" />
</ion-toolbar>
</ion-footer>

View File

@ -1,27 +1,27 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate"></ion-back-button>
<ion-back-button [text]="'core.back' | translate" />
</ion-buttons>
<ion-title>
<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>
<core-context-menu />
<core-user-menu-button />
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<core-split-view>
<ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="refreshData($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}" />
</ion-refresher>
<core-search-box (onSubmit)="searchMessage($event)" (onClear)="clearSearch()" [placeholder]=" 'addon.messages.message' | translate"
autocorrect="off" spellcheck="false" lengthCheck="2" [disabled]="!loaded" searchArea="AddonMessagesDiscussions"
[autoFocus]="false"></core-search-box>
[autoFocus]="false" />
<core-loading [hideUntil]="loaded" [message]="loadingMessage">
@ -29,7 +29,7 @@
<ion-item class="ion-text-wrap addon-message-discussion" (click)="gotoContacts()"
[attr.aria-label]="'addon.messages.contacts' | translate" [detail]="true" button>
<ion-icon name="fas-address-book" slot="start" aria-hidden="true"></ion-icon>
<ion-icon name="fas-address-book" slot="start" aria-hidden="true" />
<ion-label>
<p class="item-heading">{{ 'addon.messages.contacts' | translate }}</p>
</ion-label>
@ -46,13 +46,13 @@
</ion-item-divider>
<ion-item class="ion-text-wrap addon-message-discussion" *ngFor="let result of search.results" button
[attr.aria-label]="result.fullname" (click)="gotoDiscussion(result.userid, result.messageid)"
[attr.aria-current]="result.userid == discussionUserId ? 'page' : 'false'" [detail]="false">
<core-user-avatar [user]="result" slot="start" [checkOnline]="result.showonlinestatus"></core-user-avatar>
[attr.aria-current]="result.userid === discussionUserId ? 'page' : 'false'" [detail]="false">
<core-user-avatar [user]="result" slot="start" [checkOnline]="result.showonlinestatus" />
<ion-label>
<p class="item-heading">{{ result.fullname }}</p>
<p>
<core-format-text clean="true" singleLine="true" [text]="result.lastmessage" contextLevel="system"
[contextInstanceId]="0"></core-format-text>
[contextInstanceId]="0" />
</p>
</ion-label>
</ion-item>
@ -60,8 +60,8 @@
<ng-container *ngIf="!search.showResults">
<ion-item class="ion-text-wrap addon-message-discussion" *ngFor="let discussion of discussions" button
[attr.aria-label]="discussion.fullname" (click)="gotoDiscussion(discussion.message!.user)"
[attr.aria-current]="discussion.message!.user == discussionUserId ? 'page' : 'false'" [detail]="false">
<core-user-avatar [user]="discussion" slot="start" checkOnline="false"></core-user-avatar>
[attr.aria-current]="discussion.message!.user === discussionUserId ? 'page' : 'false'" [detail]="false">
<core-user-avatar [user]="discussion" slot="start" checkOnline="false" />
<ion-label>
<div class="flex-row ion-justify-content-between">
<p class="item-heading">{{ discussion.fullname }}</p>
@ -69,8 +69,7 @@
<span *ngIf="discussion.message!.timecreated > 0" class="addon-message-last-message-date">
{{discussion.message!.timecreated / 1000 | coreDateDayOrTime}}
</span>
<ion-icon *ngIf="discussion.unread" name="fas-circle" color="primary" aria-hidden="true">
</ion-icon>
<ion-icon *ngIf="discussion.unread" name="fas-circle" color="primary" aria-hidden="true" />
<span *ngIf="discussion.unread" class="sr-only">
{{ 'addon.messages.unreadmessages' | translate }}
</span>
@ -78,8 +77,7 @@
</div>
<p>
<core-format-text clean="true" singleLine="true" [text]="discussion.message!.message" contextLevel="system"
[contextInstanceId]="0">
</core-format-text>
[contextInstanceId]="0" />
</p>
</ion-label>
</ion-item>
@ -87,10 +85,10 @@
</ion-list>
<core-empty-box *ngIf="(!discussions || discussions.length <= 0) && !search.showResults" icon="far-comments"
[message]="'addon.messages.nomessagesfound' | translate"></core-empty-box>
[message]="'addon.messages.nomessagesfound' | translate" />
<core-empty-box *ngIf="(!search.results || search.results.length <= 0) && search.showResults" icon="fas-magnifying-glass"
[message]="'core.noresults' | translate"></core-empty-box>
[message]="'core.noresults' | translate" />
</core-loading>
</core-split-view>
</ion-content>

View File

@ -1,34 +1,34 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate"></ion-back-button>
<ion-back-button [text]="'core.back' | translate" />
</ion-buttons>
<ion-title>
<h1>{{ 'addon.messages.messages' | translate }}</h1>
</ion-title>
<ion-buttons slot="end">
<ion-button fill="clear" (click)="gotoSearch()" [attr.aria-label]="'addon.messages.searchcombined' | translate">
<ion-icon name="fas-magnifying-glass" slot="icon-only" aria-hidden="true"></ion-icon>
<ion-icon name="fas-magnifying-glass" slot="icon-only" aria-hidden="true" />
</ion-button>
<ion-button (click)="gotoSettings()" [attr.aria-label]="'addon.messages.messagepreferences' | translate">
<ion-icon name="fas-gear" slot="icon-only" aria-hidden="true"></ion-icon>
<ion-icon name="fas-gear" slot="icon-only" aria-hidden="true" />
</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>
<core-context-menu />
<core-user-menu-button />
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<core-split-view>
<ion-refresher slot="fixed" [disabled]="!loaded || !currentListEl" (ionRefresh)="refreshData($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}" />
</ion-refresher>
<core-loading [hideUntil]="loaded" [message]="loadingMessage">
<ion-list>
<ion-item class="ion-text-wrap addon-message-discussion" (click)="gotoContacts()" [detail]="true" button>
<ion-icon name="fas-address-book" slot="start" aria-hidden="true"></ion-icon>
<ion-icon name="fas-address-book" slot="start" aria-hidden="true" />
<ion-label>
<h2>{{ 'addon.messages.contacts' | translate }}</h2>
</ion-label>
@ -43,8 +43,7 @@
[attr.aria-expanded]="favourites.expanded" aria-controls="addon-messages-groupconversations-favourite" role="heading"
[detail]="false">
<ion-icon name="fas-chevron-right" flip-rtl slot="start" aria-hidden="true" class="expandable-status-icon"
[class.expandable-status-icon-expanded]="favourites.expanded">
</ion-icon>
[class.expandable-status-icon-expanded]="favourites.expanded" />
<ion-label>
<h2>{{ 'core.favourites' | translate }} ({{ favourites.count }})</h2>
</ion-label>
@ -55,12 +54,11 @@
</ion-item>
<div [hidden]="!favourites.conversations || !favourites.expanded || favourites.loading" #favlist
id="addon-messages-groupconversations-favourite">
<ng-container *ngTemplateOutlet="conversationsTemplate; context: {conversations: favourites.conversations}">
</ng-container>
<ng-container *ngTemplateOutlet="conversationsTemplate; context: {conversations: favourites.conversations}" />
<!-- The infinite loading cannot be inside the ng-template, it fails because it doesn't find ion-content. -->
<core-infinite-loading [enabled]="favourites.canLoadMore" (action)="loadMoreConversations(favourites, $event)"
[error]="favourites.loadMoreError"></core-infinite-loading>
<ion-item class="ion-text-wrap" *ngIf="favourites.conversations && favourites.conversations.length == 0">
[error]="favourites.loadMoreError" />
<ion-item class="ion-text-wrap" *ngIf="favourites.conversations && favourites.conversations.length === 0">
<ion-label>
<p>{{ 'addon.messages.nofavourites' | translate }}</p>
</ion-label>
@ -68,7 +66,7 @@
</div>
<ion-item class="ion-text-center" *ngIf="favourites.loading">
<ion-label>
<ion-spinner [attr.aria-label]="'core.loading' | translate"></ion-spinner>
<ion-spinner [attr.aria-label]="'core.loading' | translate" />
</ion-label>
</ion-item>
@ -77,8 +75,7 @@
[attr.aria-label]="(group.expanded ? 'core.collapse' : 'core.expand') | translate" [attr.aria-expanded]="group.expanded"
aria-controls="addon-messages-groupconversations-group" role="heading" [detail]="false">
<ion-icon name="fas-chevron-right" flip-rtl slot="start" aria-hidden="true" class="expandable-status-icon"
[class.expandable-status-icon-expanded]="group.expanded">
</ion-icon>
[class.expandable-status-icon-expanded]="group.expanded" />
<ion-label>
<h2>{{ 'addon.messages.groupconversations' | translate }} ({{ group.count }})</h2>
</ion-label>
@ -89,12 +86,11 @@
</ion-item>
<div [hidden]="!group.conversations || !group.expanded || group.loading" #grouplist
id="addon-messages-groupconversations-group">
<ng-container *ngTemplateOutlet="conversationsTemplate; context: {conversations: group.conversations}">
</ng-container>
<ng-container *ngTemplateOutlet="conversationsTemplate; context: {conversations: group.conversations}" />
<!-- The infinite loading cannot be inside the ng-template, it fails because it doesn't find ion-content. -->
<core-infinite-loading [enabled]="group.canLoadMore" (action)="loadMoreConversations(group, $event)"
[error]="group.loadMoreError"></core-infinite-loading>
<ion-item class="ion-text-wrap" *ngIf="group.conversations && group.conversations.length == 0">
[error]="group.loadMoreError" />
<ion-item class="ion-text-wrap" *ngIf="group.conversations && group.conversations.length === 0">
<ion-label>
<p>{{ 'addon.messages.nogroupconversations' | translate }}</p>
</ion-label>
@ -102,7 +98,7 @@
</div>
<ion-item class="ion-text-center" *ngIf="group.loading">
<ion-label>
<ion-spinner [attr.aria-label]="'core.loading' | translate"></ion-spinner>
<ion-spinner [attr.aria-label]="'core.loading' | translate" />
</ion-label>
</ion-item>
@ -111,8 +107,7 @@
[attr.aria-expanded]="individual.expanded" aria-controls="addon-messages-groupconversations-individual" role="heading"
[detail]="false">
<ion-icon name="fas-chevron-right" flip-rtl slot="start" aria-hidden="true" class="expandable-status-icon"
[class.expandable-status-icon-expanded]="individual.expanded">
</ion-icon>
[class.expandable-status-icon-expanded]="individual.expanded" />
<ion-label>
<h2>{{ 'addon.messages.individualconversations' | translate }} ({{ individual.count }})</h2>
</ion-label>
@ -123,12 +118,11 @@
</ion-item>
<div [hidden]="!individual.conversations || !individual.expanded || individual.loading" #indlist
id="addon-messages-groupconversations-individual">
<ng-container *ngTemplateOutlet="conversationsTemplate; context: {conversations: individual.conversations}">
</ng-container>
<ng-container *ngTemplateOutlet="conversationsTemplate; context: {conversations: individual.conversations}" />
<!-- The infinite loading cannot be inside the ng-template, it fails because it doesn't find ion-content. -->
<core-infinite-loading [enabled]="individual.canLoadMore" (action)="loadMoreConversations(individual, $event)"
[error]="individual.loadMoreError"></core-infinite-loading>
<ion-item class="ion-text-wrap" *ngIf="individual.conversations && individual.conversations.length == 0">
[error]="individual.loadMoreError" />
<ion-item class="ion-text-wrap" *ngIf="individual.conversations && individual.conversations.length === 0">
<ion-label>
<p>{{ 'addon.messages.noindividualconversations' | translate }}</p>
</ion-label>
@ -136,7 +130,7 @@
</div>
<ion-item class="ion-text-center" *ngIf="individual.loading">
<ion-label>
<ion-spinner [attr.aria-label]="'core.loading' | translate"></ion-spinner>
<ion-spinner [attr.aria-label]="'core.loading' | translate" />
</ion-label>
</ion-item>
@ -148,29 +142,29 @@
<!-- Template to render a list of conversations. -->
<ng-template #conversationsTemplate let-conversations="conversations">
<ion-item class="ion-text-wrap addon-message-discussion" *ngFor="let conversation of conversations" button [detail]="false"
[attr.aria-current]="((conversation.id && conversation.id === selectedConversationId) ||
(conversation.userid && conversation.userid === selectedUserId)) ? 'page': 'false'"
(click)="gotoConversation(conversation.id, conversation.userid)"
[attr.aria-current]="((conversation.id &&
conversation.id == selectedConversationId) || (conversation.userid && conversation.userid == selectedUserId)) ? 'page': 'false'"
id="addon-message-conversation-{{ conversation.id ? conversation.id : 'user-' + conversation.userid }}"
[attr.aria-label]="conversation.name">
<!-- Group conversation image. -->
<ion-avatar slot="start" *ngIf="conversation.type == typeGroup">
<ion-avatar slot="start" *ngIf="conversation.type === typeGroup">
<img [src]="conversation.imageurl" [alt]="conversation.name" core-external-content
onError="this.src='assets/img/group-avatar.svg'">
</ion-avatar>
<!-- Avatar for individual conversations. -->
<core-user-avatar *ngIf="conversation.type != typeGroup" core-user-avatar [user]="conversation.otherUser" [linkProfile]="false"
[checkOnline]="conversation.showonlinestatus" slot="start"></core-user-avatar>
<core-user-avatar *ngIf="conversation.type !== typeGroup" core-user-avatar [user]="conversation.otherUser" [linkProfile]="false"
[checkOnline]="conversation.showonlinestatus" slot="start" />
<ion-label>
<div class="flex-row ion-justify-content-between">
<p class="item-heading">
<core-format-text [text]="conversation.name" contextLevel="system" [contextInstanceId]="0"></core-format-text>
<ion-icon name="fas-user-slash" *ngIf="conversation.isblocked" [title]="'addon.messages.contactblocked' | translate">
</ion-icon>
<ion-icon *ngIf="conversation.ismuted" name="fas-volume-xmark" [title]="'addon.messages.mutedconversation' | translate">
</ion-icon>
<core-format-text [text]="conversation.name" contextLevel="system" [contextInstanceId]="0" />
<ion-icon name="fas-user-slash" *ngIf="conversation.isblocked"
[attr.aria-label]="'addon.messages.contactblocked' | translate" />
<ion-icon *ngIf="conversation.ismuted" name="fas-volume-xmark"
[title]="'addon.messages.mutedconversation' | translate" />
</p>
<ion-note *ngIf="conversation.lastmessagedate > 0 || conversation.unreadcount">
<span *ngIf="conversation.lastmessagedate > 0" class="addon-message-last-message-date">
@ -183,16 +177,16 @@
</ion-note>
</div>
<p *ngIf="conversation.subname">
<core-format-text [text]="conversation.subname" contextLevel="system" [contextInstanceId]="0"></core-format-text>
<core-format-text [text]="conversation.subname" contextLevel="system" [contextInstanceId]="0" />
</p>
<p *ngIf="conversation.lastmessage !== undefined" class="addon-message-last-message">
<span *ngIf="conversation.sentfromcurrentuser" class="addon-message-last-message-user">
{{ 'addon.messages.you' | translate }}
</span>
<span *ngIf="!conversation.sentfromcurrentuser && conversation.type == typeGroup && conversation.members[0]"
<span *ngIf="!conversation.sentfromcurrentuser && conversation.type === typeGroup && conversation.members[0]"
class="addon-message-last-message-user">{{ conversation.members[0].fullname + ':' }}</span>
<core-format-text clean="true" singleLine="true" [text]="conversation.lastmessage" class="addon-message-last-message-text"
contextLevel="system" [contextInstanceId]="0"></core-format-text>
contextLevel="system" [contextInstanceId]="0" />
</p>
</ion-label>
</ion-item>

View File

@ -1,35 +1,34 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate"></ion-back-button>
<ion-back-button [text]="'core.back' | translate" />
</ion-buttons>
<ion-title>
<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>
<core-context-menu />
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<core-split-view>
<core-search-box (onSubmit)="search($event)" (onClear)="clearSearch()" [disabled]="disableSearch" autocorrect="off"
[spellcheck]="false" [autoFocus]="true" [lengthCheck]="1" searchArea="AddonMessagesSearch"></core-search-box>
[spellcheck]="false" [autoFocus]="true" [lengthCheck]="1" searchArea="AddonMessagesSearch" />
<core-loading [hideUntil]="!displaySearching" [message]="'core.searching' | translate">
<ion-list *ngIf="displayResults">
<ng-container *ngTemplateOutlet="resultsTemplate; context: {item: contacts}"></ng-container>
<ng-container *ngTemplateOutlet="resultsTemplate; context: {item: nonContacts}"></ng-container>
<ng-container *ngTemplateOutlet="resultsTemplate; context: {item: messages}"></ng-container>
<ng-container *ngTemplateOutlet="resultsTemplate; context: {item: contacts}" />
<ng-container *ngTemplateOutlet="resultsTemplate; context: {item: nonContacts}" />
<ng-container *ngTemplateOutlet="resultsTemplate; context: {item: messages}" />
<!-- The infinite loading cannot be inside the ng-template, it fails because it doesn't find ion-content. -->
<core-infinite-loading [enabled]="messages.canLoadMore" (action)="search(query, 'messages', $event)"
[error]="messages.loadMoreError"></core-infinite-loading>
[error]="messages.loadMoreError" />
</ion-list>
<core-empty-box *ngIf="displayResults && !contacts.results.length && !nonContacts.results.length && !messages.results.length"
icon="fas-magnifying-glass" [message]="'core.noresults' | translate">
</core-empty-box>
icon="fas-magnifying-glass" [message]="'core.noresults' | translate" />
</core-loading>
</core-split-view>
</ion-content>
@ -45,14 +44,12 @@
<!-- List of results -->
<ion-item class="addon-message-discussion ion-text-wrap" *ngFor="let result of item.results" [attr.aria-label]="result.fullname"
(click)="openConversation(result)" [attr.aria-current]="result == selectedResult ? 'page' : 'false'" [detail]="true" button>
<core-user-avatar slot="start" [user]="result" [checkOnline]="true" [linkProfile]="false"></core-user-avatar>
(click)="openConversation(result)" [attr.aria-current]="result === selectedResult ? 'page' : 'false'" [detail]="true" button>
<core-user-avatar slot="start" [user]="result" [checkOnline]="true" [linkProfile]="false" />
<ion-label>
<p class="item-heading">
<core-format-text [text]="result.fullname" [highlight]="result.highlightName" [filter]="false">
</core-format-text>
<ion-icon name="fas-ban" *ngIf="result.isblocked" [attr.aria-label]="'addon.messages.contactblocked' | translate">
</ion-icon>
<core-format-text [text]="result.fullname" [highlight]="result.highlightName" [filter]="false" />
<ion-icon name="fas-ban" *ngIf="result.isblocked" [attr.aria-label]="'addon.messages.contactblocked' | translate" />
</p>
<ion-note *ngIf="result.lastmessagedate > 0">
{{result.lastmessagedate | coreDateDayOrTime}}
@ -62,7 +59,7 @@
{{ 'addon.messages.you' | translate }}
</span>
<core-format-text clean="true" singleLine="true" [text]="result.lastmessage" [highlight]="result.highlightMessage"
contextLevel="system" [contextInstanceId]="0" class="addon-message-last-message-text"></core-format-text>
contextLevel="system" [contextInstanceId]="0" class="addon-message-last-message-text" />
</p>
</ion-label>
</ion-item>
@ -75,7 +72,7 @@
</ion-button>
</div>
<div *ngIf="item.loadingMore" class="ion-padding ion-text-center">
<ion-spinner [attr.aria-label]="'core.loading' | translate"></ion-spinner>
<ion-spinner [attr.aria-label]="'core.loading' | translate" />
</div>
</ng-container>
</ng-container>

View File

@ -1,7 +1,7 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate"></ion-back-button>
<ion-back-button [text]="'core.back' | translate" />
</ion-buttons>
<ion-title>
<h1>{{ 'addon.messages.messages' | translate }}</h1>
@ -10,7 +10,7 @@
</ion-header>
<ion-content>
<ion-refresher slot="fixed" [disabled]="!preferencesLoaded" (ionRefresh)="refreshPreferences($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}" />
</ion-refresher>
<core-loading [hideUntil]="preferencesLoaded">
<!-- General settings. -->
@ -22,21 +22,18 @@
</ion-label>
</ion-item-divider>
<ion-item class="ion-text-wrap">
<ion-label>
<p>{{ 'addon.messages.useentertosend' | translate }}</p>
</ion-label>
<ion-toggle [(ngModel)]="sendOnEnter" (ngModelChange)="sendOnEnterChanged()" slot="end"></ion-toggle>
<ion-toggle [(ngModel)]="sendOnEnter" (ngModelChange)="sendOnEnterChanged()">
{{ 'addon.messages.useentertosend' | translate }}
</ion-toggle>
</ion-item>
</ion-list>
</ion-card>
<!-- Contactable privacy. -->
<ion-card>
<ion-item *ngIf="!advancedContactable">
<ion-label class="ion-text-wrap">
<p>{{ 'addon.messages.blocknoncontacts' | translate }}</p>
</ion-label>
<ion-toggle [(ngModel)]="contactablePrivacy" (ngModelChange)="saveContactablePrivacy(contactablePrivacy)" slot="end">
<ion-item *ngIf="!advancedContactable" class="ion-text-wrap">
<ion-toggle [(ngModel)]="contactablePrivacy" (ngModelChange)="saveContactablePrivacy(contactablePrivacy)">
{{ 'addon.messages.blocknoncontacts' | translate }}
</ion-toggle>
</ion-item>
@ -48,22 +45,19 @@
</ion-label>
</ion-item-divider>
<ion-item class="ion-text-wrap">
<ion-label>
<p>{{ 'addon.messages.contactableprivacy_onlycontacts' | translate }}</p>
</ion-label>
<ion-radio slot="start" [value]="onlyContactsValue"></ion-radio>
<ion-radio labelPlacement="end" justify="start" [value]="onlyContactsValue">
{{ 'addon.messages.contactableprivacy_onlycontacts' | translate }}
</ion-radio>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
<p>{{ 'addon.messages.contactableprivacy_coursemember' | translate }}</p>
</ion-label>
<ion-radio slot="start" [value]="courseMemberValue"></ion-radio>
<ion-radio labelPlacement="end" justify="start" [value]="courseMemberValue">
{{ 'addon.messages.contactableprivacy_coursemember' | translate }}
</ion-radio>
</ion-item>
<ion-item *ngIf="allowSiteMessaging" class="ion-text-wrap">
<ion-label>
<p>{{ 'addon.messages.contactableprivacy_site' | translate }}</p>
</ion-label>
<ion-radio slot="start" [value]="siteValue"></ion-radio>
<ion-radio labelPlacement="end" justify="start" [value]="siteValue">
{{ 'addon.messages.contactableprivacy_site' | translate }}
</ion-radio>
</ion-item>
</ion-radio-group>
</ion-list>
@ -72,10 +66,10 @@
<!-- Notifications. -->
<ng-container *ngIf="preferences">
<ng-container *ngIf="!groupMessagingEnabled">
<ng-container *ngTemplateOutlet="legacySettings; context: {preferences: preferences}"></ng-container>
<ng-container *ngTemplateOutlet="legacySettings; context: {preferences: preferences}" />
</ng-container>
<ng-container *ngIf="groupMessagingEnabled">
<ng-container *ngTemplateOutlet="settings; context: {preferences: preferences}"></ng-container>
<ng-container *ngTemplateOutlet="settings; context: {preferences: preferences}" />
</ng-container>
</ng-container>
</core-loading>
@ -109,8 +103,7 @@
<!-- If notifications enabled, show toggle. -->
<core-button-with-spinner *ngIf="!processor.locked" [loading]="notification['updating'+state]" slot="end">
<ion-toggle [(ngModel)]="processor[state].checked"
(ngModelChange)="changePreferenceLegacy(notification, processor, state)">
</ion-toggle>
(ngModelChange)="changePreferenceLegacy(notification, processor, state)" />
</core-button-with-spinner>
<span *ngIf="processor.locked && processor[state].checked" class="text-gray" slot="end">
{{'core.settings.forced' | translate }}
@ -145,8 +138,7 @@
<ng-container *ngIf="!preferences.disableall">
<!-- If notifications enabled, show toggle. -->
<core-button-with-spinner *ngIf="!processor.locked" [loading]="notification.updating" slot="end">
<ion-toggle [(ngModel)]="processor.enabled" (ngModelChange)="changePreference(notification, processor)">
</ion-toggle>
<ion-toggle [(ngModel)]="processor.enabled" (ngModelChange)="changePreference(notification, processor)" />
</core-button-with-spinner>
<span class="text-gray" *ngIf="processor.locked" slot="end">
{{ processor.lockedmessage }}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -16,7 +16,7 @@ import { conditionalRoutes } from '@/app/app-routing.module';
import { CoreSharedModule } from '@/core/shared.module';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { CanLeaveGuard } from '@guards/can-leave';
import { canLeaveGuard } from '@guards/can-leave';
import { CoreScreen } from '@services/screen';
import { AddonModAssignComponentsModule } from './components/components.module';
import { AddonModAssignEditPage } from './pages/edit/edit';
@ -32,7 +32,7 @@ const commonRoutes: Routes = [
{
path: ':courseId/:cmId/edit',
component: AddonModAssignEditPage,
canDeactivate: [CanLeaveGuard],
canDeactivate: [canLeaveGuard],
},
];
@ -45,7 +45,7 @@ const mobileRoutes: Routes = [
{
path: ':courseId/:cmId/submission/:submitId',
component: AddonModAssignSubmissionReviewPage,
canDeactivate: [CanLeaveGuard],
canDeactivate: [canLeaveGuard],
},
];
@ -58,7 +58,7 @@ const tabletRoutes: Routes = [
{
path: ':submitId',
component: AddonModAssignSubmissionReviewPage,
canDeactivate: [CanLeaveGuard],
canDeactivate: [canLeaveGuard],
},
],
},

View File

@ -5,15 +5,14 @@
</ion-title>
<ion-buttons slot="end">
<ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
<ion-icon slot="icon-only" name="fas-xmark" aria-hidden="true"></ion-icon>
<ion-icon slot="icon-only" name="fas-xmark" aria-hidden="true" />
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<form name="addon-mod_assign-edit-feedback-form" *ngIf="userId && plugin" #editFeedbackForm>
<addon-mod-assign-feedback-plugin [assign]="assign" [submission]="submission" [userId]="userId" [plugin]="plugin" [edit]="true">
</addon-mod-assign-feedback-plugin>
<addon-mod-assign-feedback-plugin [assign]="assign" [submission]="submission" [userId]="userId" [plugin]="plugin" [edit]="true" />
<ion-button expand="block" (click)="done($event)">{{ 'core.done' | translate }}</ion-button>
</form>
</ion-content>

View File

@ -9,12 +9,10 @@
</ion-badge>
<p *ngIf="text">
<core-format-text [component]="component" [componentId]="assign.cmid" collapsible-item [text]="text"
contextLevel="module" [contextInstanceId]="assign.cmid" [courseId]="assign.course">
</core-format-text>
contextLevel="module" [contextInstanceId]="assign.cmid" [courseId]="assign.course" />
</p>
<core-file *ngFor="let file of files" [file]="file" [component]="component" [componentId]="assign.cmid"
[alwaysDownload]="true">
</core-file>
[alwaysDownload]="true" />
</ion-label>
</ion-item>
</core-loading>

View File

@ -1,7 +1,7 @@
<!-- Buttons to add to the header. -->
<core-navbar-buttons slot="end">
<ion-button fill="clear" (click)="openModuleSummary()" aria-haspopup="true" [attr.aria-label]="'core.info' | translate">
<ion-icon name="fas-circle-info" slot="icon-only" aria-hidden="true"></ion-icon>
<ion-icon name="fas-circle-info" slot="icon-only" aria-hidden="true" />
</ion-button>
</core-navbar-buttons>
@ -12,8 +12,7 @@
<core-course-module-info [module]="module" [description]="description" [component]="component" [componentId]="componentId"
[courseId]="courseId" [hasDataToSync]="hasOffline" (completionChanged)="onCompletionChange()">
<div description *ngIf="assign && assign.introattachments?.length && !assign.submissionattachments">
<core-file *ngFor="let file of assign.introattachments" [file]="file" [component]="component" [componentId]="componentId">
</core-file>
<core-file *ngFor="let file of assign.introattachments" [file]="file" [component]="component" [componentId]="componentId" />
</div>
</core-course-module-info>
@ -21,8 +20,7 @@
<ng-container *ngIf="assign && canViewAllSubmissions">
<ion-list class="core-list-align-detail-right">
<core-group-selector [groupInfo]="groupInfo" [(selected)]="group" (selectedChange)="setGroup(group)" [courseId]="courseId">
</core-group-selector>
<core-group-selector [groupInfo]="groupInfo" [(selected)]="group" (selectedChange)="setGroup(group)" [courseId]="courseId" />
<ion-item class="ion-text-wrap">
<ion-label>
@ -115,19 +113,17 @@
<!-- Ungrouped users. -->
<ion-card *ngIf="assign.teamsubmission && summary && summary.warnofungroupedusers" class="core-info-card">
<ion-item>
<ion-icon name="fas-circle-question" slot="start" aria-hidden="true"></ion-icon>
<ion-icon name="fas-circle-question" slot="start" aria-hidden="true" />
<ion-label>{{ 'addon.mod_assign.'+summary.warnofungroupedusers | translate }}</ion-label>
</ion-item>
</ion-card>
<div collapsible-footer *ngIf="!showLoading" slot="fixed">
<core-course-module-navigation [courseId]="courseId" [currentModuleId]="module.id">
</core-course-module-navigation>
<core-course-module-navigation [courseId]="courseId" [currentModuleId]="module.id" />
</div>
</ng-container>
<!-- If it's a student, display his submission. -->
<addon-mod-assign-submission *ngIf="!showLoading && !canViewAllSubmissions && canViewOwnSubmission" [courseId]="courseId"
[moduleId]="module.id">
</addon-mod-assign-submission>
[moduleId]="module.id" />
</core-loading>

View File

@ -9,12 +9,10 @@
</ion-badge>
<p *ngIf="text">
<core-format-text [component]="component" [componentId]="assign.cmid" collapsible-item [text]="text"
contextLevel="module" [contextInstanceId]="assign.cmid" [courseId]="assign.course">
</core-format-text>
contextLevel="module" [contextInstanceId]="assign.cmid" [courseId]="assign.course" />
</p>
<core-file *ngFor="let file of files" [file]="file" [component]="component" [componentId]="assign.cmid"
[alwaysDownload]="true">
</core-file>
[alwaysDownload]="true" />
</ion-label>
</ion-item>
</core-loading>

View File

@ -3,7 +3,7 @@
<!-- Time limit is over. -->
<ion-card *ngIf="timeLimitFinished && (canEdit || canSubmit)" class="core-danger-card">
<ion-item class="ion-text-wrap">
<ion-icon name="fas-triangle-exclamation" slot="start" aria-hidden="true"></ion-icon>
<ion-icon name="fas-triangle-exclamation" slot="start" aria-hidden="true" />
<ion-label>
<p>{{ 'addon.mod_assign.caneditsubmission' | translate }}</p>
</ion-label>
@ -13,10 +13,10 @@
<!-- User and status of the submission. -->
<ion-item class="ion-text-wrap" *ngIf="!blindMarking && user" core-user-link [userId]="submitId" [courseId]="courseId"
[attr.aria-label]="user!.fullname">
<core-user-avatar [user]="user" slot="start" [linkProfile]="false"></core-user-avatar>
<core-user-avatar [user]="user" slot="start" [linkProfile]="false" />
<ion-label>
<p class="item-heading">{{ user!.fullname }}</p>
<ng-container *ngTemplateOutlet="submissionStatus"></ng-container>
<ng-container *ngTemplateOutlet="submissionStatus" />
</ion-label>
</ion-item>
@ -24,7 +24,7 @@
<ion-item class="ion-text-wrap" *ngIf="blindMarking && !user">
<ion-label>
<p class="item-heading">{{ 'addon.mod_assign.hiddenuser' | translate }} {{blindId}}</p>
<ng-container *ngTemplateOutlet="submissionStatus"></ng-container>
<ng-container *ngTemplateOutlet="submissionStatus" />
</ion-label>
</ion-item>
@ -32,7 +32,7 @@
<ion-item class="ion-text-wrap" *ngIf="(blindMarking && user) || (!blindMarking && !user)">
<ion-label>
<p class="item-heading">{{ 'addon.mod_assign.submissionstatus' | translate }}</p>
<ng-container *ngTemplateOutlet="submissionStatus"></ng-container>
<ng-container *ngTemplateOutlet="submissionStatus" />
</ion-label>
</ion-item>
@ -45,11 +45,11 @@
<ion-item class="ion-text-wrap" *ngIf="currentAttempt && !isGrading">
<ion-label>
<p class="item-heading">{{ 'addon.mod_assign.attemptnumber' | translate }}</p>
<p *ngIf="assign!.maxattempts == unlimitedAttempts">
<p *ngIf="assign!.maxattempts === unlimitedAttempts">
{{ 'addon.mod_assign.outof' | translate :
{'$a': {'current': currentAttempt, 'total': maxAttemptsText} } }}
</p>
<p *ngIf="assign!.maxattempts != unlimitedAttempts">
<p *ngIf="assign!.maxattempts !== unlimitedAttempts">
{{ 'addon.mod_assign.outof' | translate :
{'$a': {'current': currentAttempt, 'total': assign!.maxattempts} } }}
</p>
@ -103,8 +103,7 @@
<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()">
</core-timer>
[timeLeftClassThreshold]="-1" [underTimeClassThresholds]="[300, 900]" (finished)="timeUp()" />
</ion-label>
</ion-item>
@ -128,7 +127,7 @@
<!-- Last modified. -->
<ion-item class="ion-text-wrap"
*ngIf="userSubmission && userSubmission!.status != statusNew && userSubmission!.timemodified">
*ngIf="userSubmission && userSubmission!.status !== statusNew && userSubmission!.timemodified">
<ion-label>
<p class="item-heading">{{ 'addon.mod_assign.timemodified' | translate }}</p>
<p>{{ userSubmission!.timemodified * 1000 | coreFormatDate }}</p>
@ -136,8 +135,7 @@
</ion-item>
<addon-mod-assign-submission-plugin *ngFor="let plugin of submissionPlugins" [assign]="assign"
[submission]="userSubmission" [plugin]="plugin">
</addon-mod-assign-submission-plugin>
[submission]="userSubmission" [plugin]="plugin" />
<!-- Team members that need to submit it too. -->
<ion-item-divider class="ion-text-wrap" *ngIf="membersToSubmit && membersToSubmit.length > 0">
@ -149,7 +147,7 @@
<ng-container *ngFor="let user of membersToSubmit">
<ion-item class="ion-text-wrap" core-user-link [userId]="user.id" [courseId]="courseId"
[attr.aria-label]="user.fullname">
<core-user-avatar [user]="user" slot="start" [linkProfile]="false"></core-user-avatar>
<core-user-avatar [user]="user" slot="start" [linkProfile]="false" />
<ion-label>
<p class="item-heading">{{ user.fullname }}</p>
</ion-label>
@ -175,7 +173,7 @@
</ion-button>
<!-- If no submission or is new, show add submission. -->
<ion-button expand="block" class="ion-text-wrap" (click)="goToEdit()" *ngIf="!hasOffline &&
(!userSubmission || !userSubmission!.status || userSubmission!.status == statusNew)">
(!userSubmission || !userSubmission!.status || userSubmission!.status === statusNew)">
<ng-container *ngIf="!assign?.timelimit || userSubmission?.timestarted">
{{ 'addon.mod_assign.addsubmission' | translate }}
</ng-container>
@ -184,7 +182,7 @@
</ng-container>
</ion-button>
<!-- If reopened, show addfromprevious and addnewattempt. -->
<ng-container *ngIf="!hasOffline && userSubmission?.status == statusReopened">
<ng-container *ngIf="!hasOffline && userSubmission?.status === statusReopened">
<ion-button *ngIf="!isPreviousAttemptEmpty" expand="block" class="ion-text-wrap"
(click)="copyPrevious()">
{{ 'addon.mod_assign.addnewattemptfromprevious' | translate }}
@ -195,8 +193,8 @@
</ng-container>
<!-- Else show editsubmission. -->
<ion-button expand="block" class="ion-text-wrap" *ngIf="!hasOffline && userSubmission &&
userSubmission!.status && userSubmission!.status != statusNew &&
userSubmission!.status != statusReopened" (click)="goToEdit()">
userSubmission!.status && userSubmission!.status !== statusNew &&
userSubmission!.status !== statusReopened" (click)="goToEdit()">
{{ 'addon.mod_assign.editsubmission' | translate }}
</ion-button>
</ng-container>
@ -213,7 +211,7 @@
<ion-button expand="block" *ngIf="submissionUrl" [href]="submissionUrl" core-link
[showBrowserWarning]="false">
{{ 'core.openinbrowser' | translate }}
<ion-icon name="fas-up-right-from-square" slot="end" aria-hidden="true"></ion-icon>
<ion-icon name="fas-up-right-from-square" slot="end" aria-hidden="true" />
</ion-button>
</ng-container>
@ -228,10 +226,8 @@
<!-- Submit for grading form. -->
<ng-container *ngIf="canSubmit">
<ion-item class="ion-text-wrap" *ngIf="submissionStatement">
<ion-label>
<core-format-text [text]="submissionStatement" [filter]="false"></core-format-text>
</ion-label>
<ion-checkbox slot="end" name="submissionstatement" [(ngModel)]="acceptStatement">
<ion-checkbox name="submissionstatement" [(ngModel)]="acceptStatement">
<core-format-text [text]="submissionStatement" [filter]="false" />
</ion-checkbox>
</ion-item>
<!-- Submit button. -->
@ -253,8 +249,7 @@
</ion-item>
</ng-container>
</div>
<core-course-module-navigation [courseId]="courseId" [currentModuleId]="moduleId">
</core-course-module-navigation>
<core-course-module-navigation [courseId]="courseId" [currentModuleId]="moduleId" />
</div>
</ng-template>
</core-tab>
@ -268,12 +263,12 @@
<ion-label>
<p class="item-heading">{{ 'addon.mod_assign.currentgrade' | translate }}</p>
<p>
<core-format-text [text]="feedback!.gradefordisplay" [filter]="false"></core-format-text>
<core-format-text [text]="feedback!.gradefordisplay" [filter]="false" />
</p>
</ion-label>
<ion-button slot="end" *ngIf="feedback!.advancedgrade" (click)="showAdvancedGrade()"
[attr.aria-label]="'core.showadvanced' |translate">
<ion-icon name="fas-magnifying-glass" slot="icon-only" aria-hidden="true"></ion-icon>
<ion-icon name="fas-magnifying-glass" slot="icon-only" aria-hidden="true" />
</ion-button>
</ion-item>
@ -281,23 +276,18 @@
<!-- Numeric grade.
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">
<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">
</ion-input>
<p *ngIf="grade.disabled">{{ 'addon.mod_assign.gradelocked' | translate }}</p>
[lang]="grade.lang" [label]="'addon.mod_assign.gradeoutof' | translate: {$a: gradeInfo!.grade}"
labelPlacement="stacked"
[helperText]="grade.disabled ? ('addon.mod_assign.gradelocked' | translate) : null" />
</ion-item>
<!-- Grade using a scale. -->
<ion-item class="ion-text-wrap" *ngIf="grade.method === 'simple' && grade.scale">
<ion-label>
<p class="item-heading">{{ 'addon.mod_assign.grade' | translate }}</p>
</ion-label>
<ion-select [(ngModel)]="grade.grade" interface="action-sheet" [disabled]="grade.disabled"
[cancelText]="'core.cancel' | translate"
[interfaceOptions]="{header: 'addon.mod_assign.grade' | translate}">
<p class="item-heading" slot="label">{{ 'addon.mod_assign.grade' | translate }}</p>
<ion-select-option *ngFor="let grade of grade.scale" [value]="grade.value">
{{grade.label}}
</ion-select-option>
@ -306,12 +296,10 @@
<!-- Outcomes. -->
<ion-item class="ion-text-wrap" *ngFor="let outcome of gradeInfo!.outcomes">
<ion-label>
<p class="item-heading">{{ outcome.name }}</p>
</ion-label>
<ion-select *ngIf="canSaveGrades && outcome.itemNumber" [(ngModel)]="outcome.selectedId"
interface="action-sheet" [disabled]="gradeInfo!.disabled" [cancelText]="'core.cancel' | translate"
[interfaceOptions]="{header: outcome.name }">
<p class="item-heading" slot="label">{{ outcome.name }}</p>
<ion-select-option *ngFor="let grade of outcome.options" [value]="grade.value">
{{grade.label}}
</ion-select-option>
@ -345,8 +333,7 @@
<ng-container *ngIf="feedback">
<addon-mod-assign-feedback-plugin *ngFor="let plugin of feedback.plugins" [assign]="assign"
[submission]="userSubmission" [userId]="submitId" [plugin]="plugin" [canEdit]="canSaveGrades">
</addon-mod-assign-feedback-plugin>
[submission]="userSubmission" [userId]="submitId" [plugin]="plugin" [canEdit]="canSaveGrades" />
</ng-container>
<!-- Workflow status. -->
@ -359,23 +346,22 @@
<!--- Apply grade to all team members. -->
<ion-item class="ion-text-wrap" *ngIf="assign!.teamsubmission && canSaveGrades">
<ion-label>
<ion-toggle [(ngModel)]="grade.applyToAll">
<p class="item-heading">{{ 'addon.mod_assign.groupsubmissionsettings' | translate }}</p>
<p>{{ 'addon.mod_assign.applytoteam' | translate }}</p>
</ion-label>
<ion-toggle [(ngModel)]="grade.applyToAll" slot="end"></ion-toggle>
</ion-toggle>
</ion-item>
<!-- Attempt status. -->
<ng-container *ngIf="isGrading && assign!.attemptreopenmethod != attemptReopenMethodNone">
<ng-container *ngIf="isGrading && assign!.attemptreopenmethod !== attemptReopenMethodNone">
<ion-item class="ion-text-wrap">
<ion-label>
<p class="item-heading">{{ 'addon.mod_assign.attemptsettings' | translate }}</p>
<p *ngIf="assign!.maxattempts == unlimitedAttempts">
<p *ngIf="assign!.maxattempts === unlimitedAttempts">
{{ 'addon.mod_assign.outof' | translate :
{'$a': {'current': currentAttempt, 'total': maxAttemptsText} } }}
</p>
<p *ngIf="assign!.maxattempts != unlimitedAttempts">
<p *ngIf="assign!.maxattempts !== unlimitedAttempts">
{{ 'addon.mod_assign.outof' | translate :
{'$a': {'current': currentAttempt, 'total': assign!.maxattempts} } }}
</p>
@ -386,18 +372,19 @@
</ion-label>
</ion-item>
<ion-item *ngIf="canSaveGrades && allowAddAttempt">
<ion-label>{{ 'addon.mod_assign.addattempt' | translate }}</ion-label>
<ion-toggle [(ngModel)]="grade.addAttempt" slot="end"></ion-toggle>
<ion-toggle [(ngModel)]="grade.addAttempt">
<p>{{ 'addon.mod_assign.addattempt' | translate }}</p>
</ion-toggle>
</ion-item>
</ng-container>
<!-- Data about the grader (teacher who graded). -->
<ion-item class="ion-text-wrap" *ngIf="grader" core-user-link [userId]="grader!.id" [courseId]="courseId"
[attr.aria-label]="grader!.fullname" [detail]="true">
<core-user-avatar [user]="grader" slot="start" [linkProfile]="false"></core-user-avatar>
<ion-item class="ion-text-wrap" *ngIf="grader" core-user-link [userId]="grader.id" [courseId]="courseId"
[attr.aria-label]="grader.fullname" [detail]="true">
<core-user-avatar [user]="grader" slot="start" [linkProfile]="false" />
<ion-label>
<p class="item-heading">{{ 'addon.mod_assign.gradedby' | translate }}</p>
<p class="item-heading">{{ grader!.fullname }}</p>
<p class="item-heading">{{ grader.fullname }}</p>
<p *ngIf="feedback!.gradeddate">{{ feedback!.gradeddate * 1000 | coreFormatDate }}</p>
</ion-label>
</ion-item>
@ -413,12 +400,12 @@
<!-- Warning message if cannot save grades. -->
<ion-card *ngIf="isGrading && !canSaveGrades" class="core-warning-card">
<ion-item>
<ion-icon name="fas-triangle-exclamation" slot="start" aria-hidden="true"></ion-icon>
<ion-icon name="fas-triangle-exclamation" slot="start" aria-hidden="true" />
<ion-label>
<p>{{ 'addon.mod_assign.cannotgradefromapp' | translate }}</p>
<ion-button expand="block" *ngIf="gradeUrl" [href]="gradeUrl" core-link [showBrowserWarning]="false">
{{ 'core.openinbrowser' | translate }}
<ion-icon name="fas-up-right-from-square" slot="end" aria-hidden="true"></ion-icon>
<ion-icon name="fas-up-right-from-square" slot="end" aria-hidden="true" />
</ion-button>
</ion-label>
</ion-item>
@ -434,8 +421,7 @@
<ng-container *ngIf="assign && assign!.teamsubmission && lastAttempt">
<p *ngIf="lastAttempt.submissiongroup && lastAttempt.submissiongroupname" class="core-groupname">
<core-format-text [text]="lastAttempt.submissiongroupname" contextLevel="course" [contextInstanceId]="courseId"
[wsNotFiltered]="true">
</core-format-text>
[wsNotFiltered]="true" />
</p>
<ng-container *ngIf="assign!.preventsubmissionnotingroup &&
!lastAttempt!.submissiongroup &&

View File

@ -1124,9 +1124,15 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy, Can
return [];
}
// Receved submission statement should not be undefined. It would mean that the WS is not returning the value.
const submissionStatementMissing = !!this.assign.requiresubmissionstatement &&
this.assign.submissionstatement === undefined;
// If received submission statement is empty, then it's not required.
if(!this.assign.submissionstatement && this.assign.submissionstatement !== undefined) {
this.assign.requiresubmissionstatement = 0;
}
this.canSubmit = !this.isSubmittedForGrading && !this.submittedOffline && (lastAttempt.cansubmit ||
(this.hasOffline && AddonModAssign.canSubmitOffline(this.assign, submissionStatus)));

View File

@ -4,18 +4,17 @@
<h2>{{ plugin.name }}</h2>
<p>
<core-format-text [component]="component" [componentId]="assign.cmid" collapsible-item [text]="text" contextLevel="module"
[contextInstanceId]="assign.cmid" [courseId]="assign.course">
</core-format-text>
[contextInstanceId]="assign.cmid" [courseId]="assign.course" />
</p>
</ion-label>
<div slot="end">
<div class="ion-text-end">
<ion-button fill="clear" *ngIf="canEdit" (click)="editComment()" [attr.aria-label]="'core.edit' | translate">
<ion-icon name="fas-pen" slot="icon-only" aria-hidden="true"></ion-icon>
<ion-icon name="fas-pen" slot="icon-only" aria-hidden="true" />
</ion-button>
</div>
<ion-note *ngIf="!isSent" color="dark">
<ion-icon name="fas-clock" aria-hidden="true"></ion-icon> {{ 'core.notsent' | translate }}
<ion-icon name="fas-clock" aria-hidden="true" /> {{ 'core.notsent' | translate }}
</ion-note>
</div>
</ion-item>
@ -25,6 +24,5 @@
<ion-label class="sr-only">{{ plugin.name }}</ion-label>
<core-rich-text-editor [control]="control" [placeholder]="plugin.name" name="assignfeedbackcomments_editor" [component]="component"
[componentId]="assign.cmid" [autoSave]="true" contextLevel="module" [contextInstanceId]="assign.cmid"
elementId="assignfeedbackcomments_editor" [draftExtraParams]="{userid: userId, action: 'grade'}">
</core-rich-text-editor>
elementId="assignfeedbackcomments_editor" [draftExtraParams]="{userid: userId, action: 'grade'}" />
</ion-item>

View File

@ -34,7 +34,7 @@ import { AddonModAssignFeedbackPluginBaseComponent } from '@addons/mod/assign/cl
})
export class AddonModAssignFeedbackCommentsComponent extends AddonModAssignFeedbackPluginBaseComponent implements OnInit {
control?: FormControl;
control?: FormControl<string>;
component = AddonModAssignProvider.COMPONENT;
text = '';
isSent = false;
@ -76,7 +76,7 @@ export class AddonModAssignFeedbackCommentsComponent extends AddonModAssignFeedb
}
});
} else if (this.edit) {
this.control = this.fb.control(this.text);
this.control = this.fb.control(this.text, { nonNullable: true });
}
} finally {
this.loaded = true;

View File

@ -3,8 +3,8 @@
<ion-label>
<h2>{{plugin.name}}</h2>
<ng-container>
<core-file *ngFor="let file of files" [file]="file" [component]="component" [componentId]="assign.cmid" [alwaysDownload]="true">
</core-file>
<core-file *ngFor="let file of files" [file]="file" [component]="component" [componentId]="assign.cmid"
[alwaysDownload]="true" />
</ng-container>
</ion-label>
</ion-item>

View File

@ -3,8 +3,8 @@
<ion-label>
<h2>{{plugin.name}}</h2>
<ng-container>
<core-file *ngFor="let file of files" [file]="file" [component]="component" [componentId]="assign.cmid" [alwaysDownload]="true">
</core-file>
<core-file *ngFor="let file of files" [file]="file" [component]="component" [componentId]="assign.cmid"
[alwaysDownload]="true" />
</ng-container>
</ion-label>
</ion-item>

View File

@ -1,12 +1,11 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate"></ion-back-button>
<ion-back-button [text]="'core.back' | translate" />
</ion-buttons>
<ion-title>
<h1>
<core-format-text [text]="title" contextLevel="module" [contextInstanceId]="moduleId" [courseId]="courseId">
</core-format-text>
<core-format-text [text]="title" contextLevel="module" [contextInstanceId]="moduleId" [courseId]="courseId" />
</h1>
</ion-title>
<ion-buttons slot="end">
@ -22,38 +21,32 @@
<!-- @todo plagiarism_print_disclosure -->
<core-timer *ngIf="timeLimitEndTime > 0" [endTime]="timeLimitEndTime" (finished)="timeUp()" timeUpText="00:00:00"
[timerText]="'addon.mod_assign.assigntimeleft' | translate" [align]="'center'" [timeLeftClassThreshold]="-1"
[underTimeClassThresholds]="[300, 900]">
</core-timer>
[underTimeClassThresholds]="[300, 900]" />
<!-- Assign activity instructions and attachments if needed. -->
<ion-item class="ion-text-wrap" *ngIf="activityInstructions">
<ion-label>
<core-format-text [text]="activityInstructions" [component]="component" [componentId]="moduleId" contextLevel="module"
[contextInstanceId]="moduleId" [courseId]="courseId">
</core-format-text>
[contextInstanceId]="moduleId" [courseId]="courseId" />
</ion-label>
</ion-item>
<ng-container *ngIf="assign?.submissionattachments">
<core-file *ngFor="let file of introAttachments" [file]="file" [component]="component" [componentId]="moduleId">
</core-file>
<core-file *ngFor="let file of introAttachments" [file]="file" [component]="component" [componentId]="moduleId" />
</ng-container>
<form name="addon-mod_assign-edit-form" #editSubmissionForm>
<!-- Submission statement. -->
<ion-item class="ion-text-wrap" *ngIf="submissionStatement">
<ion-label>
<core-format-text [text]="submissionStatement" [filter]="false">
</core-format-text>
</ion-label>
<ion-checkbox slot="end" name="submissionstatement" [(ngModel)]="submissionStatementAccepted"></ion-checkbox>
<ion-checkbox name="submissionstatement" [(ngModel)]="submissionStatementAccepted">
<core-format-text [text]="submissionStatement" [filter]="false" />
</ion-checkbox>
<!-- ion-checkbox doesn't use an input. Create a hidden input to hold the value. -->
<input type="hidden" [ngModel]="submissionStatementAccepted" name="submissionstatement">
</ion-item>
<addon-mod-assign-submission-plugin *ngFor="let plugin of userSubmission.plugins" [assign]="assign"
[submission]="userSubmission" [plugin]="plugin" [edit]="true" [allowOffline]="allowOffline">
</addon-mod-assign-submission-plugin>
[submission]="userSubmission" [plugin]="plugin" [edit]="true" [allowOffline]="allowOffline" />
</form>
</ion-list>
</core-loading>

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