forked from EVOgeek/Vmeda.Online
		
	Merge pull request #3897 from moodlehq/ionic7
MOBILE-3947: Upgrade to Ionic 7
This commit is contained in:
		
						commit
						a9f6d2ca94
					
				
							
								
								
									
										77
									
								
								.eslintrc.js
									
									
									
									
									
								
							
							
						
						
									
										77
									
								
								.eslintrc.js
									
									
									
									
									
								
							| @ -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 }], | ||||
|             }, | ||||
|         }, | ||||
|         { | ||||
|  | ||||
							
								
								
									
										10
									
								
								.github/workflows/acceptance.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								.github/workflows/acceptance.yml
									
									
									
									
										vendored
									
									
								
							| @ -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 | ||||
|  | ||||
							
								
								
									
										4
									
								
								.github/workflows/performance.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/performance.yml
									
									
									
									
										vendored
									
									
								
							| @ -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 | ||||
|  | ||||
							
								
								
									
										10
									
								
								.github/workflows/testing.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								.github/workflows/testing.yml
									
									
									
									
										vendored
									
									
								
							| @ -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
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -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
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							| @ -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. | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| ## BUILD STAGE | ||||
| FROM node:14 as build-stage | ||||
| FROM node:18 as build-stage | ||||
| 
 | ||||
| WORKDIR /app | ||||
| 
 | ||||
|  | ||||
| @ -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); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										43
									
								
								angular.json
									
									
									
									
									
								
							
							
						
						
									
										43
									
								
								angular.json
									
									
									
									
									
								
							| @ -1,7 +1,6 @@ | ||||
| { | ||||
|   "$schema": "./node_modules/@angular/cli/lib/config/schema.json", | ||||
|   "version": 1, | ||||
|   "defaultProject": "app", | ||||
|   "newProjectRoot": "projects", | ||||
|   "projects": { | ||||
|     "app": { | ||||
| @ -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,11 +101,12 @@ | ||||
|             "production": { | ||||
|               "browserTarget": "app:build:production" | ||||
|             }, | ||||
|             "ci": { | ||||
|               "progress": false | ||||
|             } | ||||
|             "development": { | ||||
|               "browserTarget": "app:build:development" | ||||
|             } | ||||
|           }, | ||||
|           "defaultConfiguration": "development" | ||||
|         }, | ||||
|         "extract-i18n": { | ||||
|           "builder": "@angular-devkit/build-angular:extract-i18n", | ||||
|           "options": { | ||||
| @ -129,7 +124,7 @@ | ||||
|           } | ||||
|         }, | ||||
|         "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": { | ||||
|  | ||||
							
								
								
									
										123
									
								
								config.xml
									
									
									
									
									
								
							
							
						
						
									
										123
									
								
								config.xml
									
									
									
									
									
								
							| @ -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" /> | ||||
|  | ||||
							
								
								
									
										1135
									
								
								cordova-plugin-moodleapp/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1135
									
								
								cordova-plugin-moodleapp/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @ -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(); | ||||
							
								
								
									
										237
									
								
								gulp/git.js
									
									
									
									
									
								
							
							
						
						
									
										237
									
								
								gulp/git.js
									
									
									
									
									
								
							| @ -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(); | ||||
							
								
								
									
										476
									
								
								gulp/jira.js
									
									
									
									
									
								
							
							
						
						
									
										476
									
								
								gulp/jira.js
									
									
									
									
									
								
							| @ -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(); | ||||
							
								
								
									
										118
									
								
								gulp/task-build-icons-json.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										118
									
								
								gulp/task-build-icons-json.js
									
									
									
									
									
										Normal 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; | ||||
| @ -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; | ||||
							
								
								
									
										79
									
								
								gulp/url.js
									
									
									
									
									
								
							
							
						
						
									
										79
									
								
								gulp/url.js
									
									
									
									
									
								
							| @ -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; | ||||
							
								
								
									
										120
									
								
								gulp/utils.js
									
									
									
									
									
								
							
							
						
						
									
										120
									
								
								gulp/utils.js
									
									
									
									
									
								
							| @ -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; | ||||
							
								
								
									
										14
									
								
								gulpfile.js
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								gulpfile.js
									
									
									
									
									
								
							| @ -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'] : []) | ||||
|     ]), | ||||
| ); | ||||
|  | ||||
| @ -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/' }), | ||||
| }; | ||||
|  | ||||
| @ -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); | ||||
|  | ||||
| @ -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); | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -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"] | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
							
								
								
									
										46262
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										46262
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										205
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										205
									
								
								package.json
									
									
									
									
									
								
							| @ -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": {} | ||||
|     } | ||||
|   }, | ||||
|   "optionalDependencies": { | ||||
|     "keytar": "^7.2.0" | ||||
|       "nl.kingsquare.cordova.background-audio": {} | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @ -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). | ||||
|           */ | ||||
							
								
								
									
										65
									
								
								patches/check-es-compat+3.1.0.patch
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								patches/check-es-compat+3.1.0.patch
									
									
									
									
									
										Normal 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);
 | ||||
| +}
 | ||||
| @ -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) { | ||||
| @ -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. | ||||
| @ -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'); | ||||
|  | ||||
| @ -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", | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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" | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -10,24 +10,19 @@ | ||||
|         } | ||||
| 
 | ||||
|         ion-button, | ||||
|         core-combobox ::ng-deep ion-button { | ||||
|             --border-width: 0; | ||||
|         core-combobox ::ng-deep ion-select { | ||||
|             --a11y-min-target-size: 40px; | ||||
|             margin: 0; | ||||
| 
 | ||||
|             .select-icon { | ||||
|                 display: none; | ||||
|         } | ||||
| 
 | ||||
|         ion-button { | ||||
|             --border-width: 0; | ||||
| 
 | ||||
|             ion-icon { | ||||
|                 font-size: 20px; | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         core-combobox ::ng-deep ion-select { | ||||
|             margin: 0; | ||||
|             --a11y-min-target-size: 40px; | ||||
|         } | ||||
| 
 | ||||
|         ion-searchbar { | ||||
|             padding: 0; | ||||
|             --height: 40px; | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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); | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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>[]; | ||||
|  | ||||
| @ -1,4 +1,5 @@ | ||||
| { | ||||
|     "ariadayfilter": "Filter timeline by date", | ||||
|     "duedate": "Due date", | ||||
|     "next30days": "Next 30 days", | ||||
|     "next3months": "Next 3 months", | ||||
|  | ||||
| @ -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 | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -142,7 +142,7 @@ | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     ion-slide { | ||||
|     swiper-slide { | ||||
|         display: block; | ||||
|         font-size: inherit; | ||||
|         justify-content: start; | ||||
|  | ||||
| @ -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 can’t or won’t 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(); | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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?.(); | ||||
|  | ||||
| @ -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-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> | ||||
|                 <core-input-errors [control]="form.controls.timestart" [errorMessages]="errors"></core-input-errors> | ||||
|                     </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-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"> | ||||
|  | ||||
| @ -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.'); | ||||
|         } | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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 | ||||
|  | ||||
| @ -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" | ||||
|  | ||||
| @ -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> | ||||
|                          /  | ||||
|                         <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"> / </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> | ||||
|  | ||||
| @ -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; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | ||||
| @ -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"> | ||||
|                              /  | ||||
|                             <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> | ||||
|  | ||||
| @ -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> | ||||
|                                  /  | ||||
|                                 <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"> / </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> | ||||
|  | ||||
| @ -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" | ||||
|  | ||||
| @ -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 }}:  | ||||
|  | ||||
| @ -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 | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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:'; | ||||
| 
 | ||||
|  | ||||
| @ -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; | ||||
|  | ||||
| @ -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; | ||||
|         }; | ||||
|     }; | ||||
|  | ||||
| @ -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. | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -12,31 +12,17 @@ | ||||
| // 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. | ||||
|  */ | ||||
| @Injectable({ providedIn: 'root' }) | ||||
| export class AddonMessagesIndexGuard implements CanActivate { | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     canActivate(route: ActivatedRouteSnapshot): UrlTree { | ||||
|         return this.guard(route); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Check if there is a pending redirect and trigger it. | ||||
|  * | ||||
|      * @returns The redirection route. | ||||
|  * @returns Route. | ||||
|  */ | ||||
|     private guard(route: ActivatedRouteSnapshot): UrlTree { | ||||
| export const messagesIndexGuard: CanActivateFn = async (route: ActivatedRouteSnapshot) => { | ||||
|     const enabled = AddonMessages.isGroupMessagingEnabled(); | ||||
|     const path = `/main/${AddonMessagesMainMenuHandlerService.PAGE_NAME}/` + ( enabled ? 'group-conversations' : 'index'); | ||||
| 
 | ||||
| @ -45,6 +31,4 @@ export class AddonMessagesIndexGuard implements CanActivate { | ||||
|     newRoute.queryParams = route.queryParams; | ||||
| 
 | ||||
|     return newRoute; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| }; | ||||
|  | ||||
| @ -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], | ||||
|         }), | ||||
|     ]; | ||||
| } | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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 | 
| @ -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], | ||||
|             }, | ||||
|         ], | ||||
|     }, | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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 && | ||||
|  | ||||
| @ -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))); | ||||
| 
 | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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; | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user