Merge branch 'master' into course-teaser
This commit is contained in:
parent
d08c03c477
commit
37db6256c1
9
.babelrc
Normal file
9
.babelrc
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"presets": [
|
||||
["@babel/preset-env", { "useBuiltIns": "usage" }]
|
||||
],
|
||||
"plugins": [
|
||||
["@babel/plugin-proposal-decorators", { "legacy": true }],
|
||||
["@babel/plugin-proposal-class-properties", { "loose": true }]
|
||||
]
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
[Dolphin]
|
||||
Timestamp=2018,3,14,10,57,55
|
||||
Timestamp=2019,6,26,19,32,25
|
||||
Version=4
|
||||
|
||||
[Settings]
|
||||
|
||||
28
.eslintrc.json
Normal file
28
.eslintrc.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es6": true,
|
||||
"jasmine": true
|
||||
},
|
||||
"extends": "eslint:recommended",
|
||||
"globals": {
|
||||
"Atomics": "readonly",
|
||||
"SharedArrayBuffer": "readonly",
|
||||
"flatpickr": "readonly",
|
||||
"$": "readonly"
|
||||
},
|
||||
"parser": "babel-eslint",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2018,
|
||||
"ecmaFeatures": {
|
||||
"legacyDecorators": true
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"no-console": "off",
|
||||
"no-extra-semi": "off",
|
||||
"semi": ["error", "always"],
|
||||
"comma-dangle": ["error", "always-multiline"],
|
||||
"quotes": ["error", "single"]
|
||||
}
|
||||
}
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@ -1,6 +1,8 @@
|
||||
dist*
|
||||
static/bundles/
|
||||
static/tmp/
|
||||
static/combined/
|
||||
node_modules/
|
||||
*.hi
|
||||
*.o
|
||||
*.sqlite3
|
||||
@ -31,4 +33,5 @@ src/Handler/Course.SnapCustom.hs
|
||||
.stack-work-*
|
||||
.directory
|
||||
tags
|
||||
test.log
|
||||
test.log
|
||||
*.dump-splices
|
||||
|
||||
26
.vscode/tasks.json
vendored
26
.vscode/tasks.json
vendored
@ -14,6 +14,7 @@
|
||||
"reveal": "always",
|
||||
"focus": false,
|
||||
"panel": "dedicated",
|
||||
"clear": true,
|
||||
"showReuseMessage": false
|
||||
}
|
||||
},
|
||||
@ -43,6 +44,31 @@
|
||||
"panel": "dedicated",
|
||||
"showReuseMessage": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "yesod:lint",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "yesod:start",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "start",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "frontend:lint",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "lint",
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
53
CHANGELOG.md
Normal file
53
CHANGELOG.md
Normal file
@ -0,0 +1,53 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
||||
|
||||
## 1.0.0 (2019-07-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **sheet corrector assigment:** minor bugfix ([749cd2f](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/749cd2f))
|
||||
* async table js util now knows current random css prefix ([cc90faf](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/cc90faf))
|
||||
* **correction assignment:** correcting lecturer's names are shown now ([16c556b](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/16c556b))
|
||||
* **corrector assignment:** sheet tabel mixed up columns sorted ([d07f53e](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/d07f53e))
|
||||
* **datepicker:** hide number input spinners in datepicker ([2073130](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/2073130))
|
||||
* **exam grading keys:** Fix spacing ([24aacef](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/24aacef))
|
||||
* **exams:** Fix registration ([1684da0](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/1684da0))
|
||||
* **fe:** style notifications acceptably for now ([fc80f08](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/fc80f08))
|
||||
* **fe-async-table:** Emulate no-js behaviour when handling pagesize ([28dcc8d](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/28dcc8d))
|
||||
* **fe-check-all:** use arrow fn to keep scope in event listeners ([09e681e](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/09e681e))
|
||||
* **fe-deflist:** avoid horizontal scroll on pages with deflist ([16d422d](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/16d422d))
|
||||
* **Help Widget, Corrector Assignment:** Modal Form closes in place; assign alerts ([89d5364](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/89d5364)), closes [#195](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/issues/195)
|
||||
* **info-lecturer:** Touch ups ([e1e26ab](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/e1e26ab))
|
||||
* **many occurrences throughout the project:** Fix typo: occurence -> occurrence everywhere ([96387cb](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/96387cb))
|
||||
* filter submission by not having corrector ([3bded50](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/3bded50))
|
||||
* minor heat correction for correction overview ([5546849](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/5546849))
|
||||
* **ratings:** disallow ratings for graded sheets without point value ([463b2b7](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/463b2b7))
|
||||
* **standard-version:** properly reset staging area before release ([5aa906e](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/5aa906e))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **corrector-assignment:** show load/submission percentages ([228cd50](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/228cd50))
|
||||
* make pagesize changes load async ([6486120](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/6486120))
|
||||
* **development:** add commitlint to ensure proper commit msgs ([dd528c1](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/dd528c1))
|
||||
* **development:** add standard-version for automatic changelog generation ([c495ef5](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/c495ef5))
|
||||
* **exams:** CRU (no D) for exams ([67a50c9](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/67a50c9))
|
||||
* **exams:** exam registration ([99184ff](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/99184ff))
|
||||
* **exams:** Form validation ([6fb1399](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/6fb1399))
|
||||
* **fe-heatmap:** add css class heated for heatmap elements ([b09b876](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/b09b876)), closes [#405](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/issues/405)
|
||||
* **forms:** Introduce more convenient form validation ([f8d0b02](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/f8d0b02))
|
||||
* **standard-version:** allow adding additional changes to release ([7ed6fe4](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/7ed6fe4))
|
||||
* **standard-version:** complete release workflow ([605e62f](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/605e62f))
|
||||
|
||||
|
||||
### Tests
|
||||
|
||||
* Does ist build with everything except for `makeClassy ''Entity`? Probably the functional dependency is to blame?! ([bb552c4](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/bb552c4))
|
||||
* removing makeCLassyFor maybe build works then? ([2550f74](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/2550f74))
|
||||
|
||||
|
||||
### BREAKING CHANGES
|
||||
|
||||
* **standard-version:** Start of new versioning schema
|
||||
81
ChangeLog.md
81
ChangeLog.md
@ -1,81 +0,0 @@
|
||||
* Version 27.03.2019
|
||||
|
||||
Kurse Veranstalter können nun mehrere Dozenten und Assistenten selbst eintragen
|
||||
|
||||
Erfassung Studiengangsdaten
|
||||
|
||||
* Version 20.03.2019
|
||||
|
||||
Kursanmeldung benötigen assoziertes Hauptfach (für Studierende mit mehreren Hauptfächern)
|
||||
|
||||
* Version 30.01.2019
|
||||
|
||||
Designänderungen
|
||||
|
||||
* Version 16.01.2019
|
||||
|
||||
Links für Bequemlichkeiten hinzugefügt (z.B. aktuelles Übungsblatt)
|
||||
|
||||
Liste zugewiesener Abgaben lassen sich nun filtern
|
||||
|
||||
Bugfix: Wenn zwischen Anzeige und Empfang eines Tabellen-Formulars Zeilen verschwinden wird nun eine sinnvolle Fehlermeldung angezeigt
|
||||
|
||||
* Version 30.11.2018
|
||||
|
||||
Bugfix: Übungsblätter im "bestehen nach Punkten"-Modus werden wieder korrekt gespeichert
|
||||
|
||||
* Version 29.11.2018
|
||||
|
||||
Bugfix: Formulare innerhalb von Tabellen funktionieren nun auch nach Javascript-Seitenwechsel oder Ändern der Sortierung
|
||||
|
||||
* Version 09.11.2018
|
||||
|
||||
Bugfix: Zahlreiche Knöpfe/Formulare funktionieren wieder bei eingeschaltetem Javascript
|
||||
|
||||
Verschiedene Verbesserungen für Korrektoren
|
||||
|
||||
* Version 19.10.2018
|
||||
|
||||
Benutzer können sich in der Testphase komplett selbst löschen
|
||||
|
||||
Hilfe Widget
|
||||
|
||||
Benachrichtigungen per eMail für einige Ereignisse
|
||||
|
||||
* Version 18.09.2018
|
||||
|
||||
Tooltips funktionieren auch ohne JavaScript
|
||||
|
||||
Kurskürzel müssen nur innerhalb eines Instituts eindeutig sein
|
||||
|
||||
User Data zeigt nun alle momentan gespeicherten Datensätze an
|
||||
|
||||
Unterstützung von Tabellenzusammenfassungen, z.B. Punktsummen
|
||||
|
||||
Intelligente Verteilung von Abgaben auf Korrektoren (z.B. bei Krankheit)
|
||||
|
||||
Übungsblätter können Abgabe von Dateien verbieten und angeben ob ZIP-Archive entpackt werden sollen
|
||||
|
||||
* Version 06.08.2018
|
||||
|
||||
Einführung einer Option, ob Dateien automatisch heruntergeladen werden sollen
|
||||
|
||||
* Version 01.08.2018
|
||||
|
||||
Verbesserter Campus-Login
|
||||
(Ersatz einer C-Bibliothek mit undokumentierter Abhängigkeit durch selbst entwickelten Haskell-Code erlaubt nun auch Umlaute.)
|
||||
|
||||
* Version 31.07.2018
|
||||
|
||||
Viele Verbesserung zur Anzeige von Korrekturen
|
||||
|
||||
Kursliste über alle Semester hinweg (Top-Level-Navigation "Kurse"), wird in Zukunft Filter/Suchfunktion erhalten
|
||||
|
||||
* Version 10.07.2018
|
||||
|
||||
Bugfixes, wählbares Format für Datum
|
||||
|
||||
* Version 03.07.2018
|
||||
|
||||
Willkommen bei Uni2work aka "You-need-to-work!"
|
||||
|
||||
130
README.md
Normal file
130
README.md
Normal file
@ -0,0 +1,130 @@
|
||||
# "Quick Start" Guide
|
||||
|
||||
The following description applies to Ubuntu and similar debian based Linux distributions.
|
||||
|
||||
## Prerequisites
|
||||
These are the things you need to do/install before you can get started working on Uni2work.
|
||||
|
||||
### Clone repository
|
||||
Clone this repository and navigate into it
|
||||
```sh
|
||||
$ git clone https://gitlab.cip.ifi.lmu.de/jost/UniWorX.git && cd UniWorX
|
||||
```
|
||||
|
||||
### `LDAP`
|
||||
LDAP is needed to handle logins.
|
||||
|
||||
Install:
|
||||
```sh
|
||||
sudo apt-get install slapd ldap-utils
|
||||
```
|
||||
|
||||
### `PostgreSQL`
|
||||
PostgreSQL will serve as database for Uni2work.
|
||||
|
||||
Install:
|
||||
```sh
|
||||
$ sudo apt-get install postgresql
|
||||
```
|
||||
|
||||
Switch to user *postgres* (got created during installation):
|
||||
```sh
|
||||
$ sudo -i -u postgres
|
||||
```
|
||||
|
||||
Add new database user *uniworx*:
|
||||
```sh
|
||||
$ createuser --interactive
|
||||
```
|
||||
|
||||
You'll get a prompt:
|
||||
|
||||
```sh
|
||||
Enter name of role to add: uniworx
|
||||
Shall the new role be a superuser? (y/n) [not exactly sure. Guess not?]
|
||||
Password: uniworx
|
||||
...
|
||||
```
|
||||
|
||||
Create database *uniworx*:
|
||||
```sh
|
||||
$ psql -c 'create database uniworx owner uniworx'
|
||||
$ psql -c 'create database uniworx_test owner uniworx'
|
||||
```
|
||||
|
||||
After you added the database switch back to your own user with `Ctrl + D`.
|
||||
|
||||
To properly access the database you now need to add a new linux user called *uniworx*. Enter "uniworx" as the password.
|
||||
```sh
|
||||
$ sudo adduser uniworx
|
||||
```
|
||||
|
||||
### `Stack`
|
||||
Stack is a toolbox for "Haskellers" to aid in developing Haskell projects.
|
||||
|
||||
Install:
|
||||
```sh
|
||||
$ curl -sSL https://get.haskellstack.org/ | sh
|
||||
```
|
||||
|
||||
Setup stack and install dependencies. This needs to be run from inside the directory you cloned this repository to:
|
||||
```sh
|
||||
$ stack setup
|
||||
```
|
||||
|
||||
During this step or the next you might get an error that says something about missing C libraries for `ldap` and `lber`. You can install these using
|
||||
```sh
|
||||
$ sudo apt-get install libsasl2-dev libldap2-dev
|
||||
```
|
||||
|
||||
If you get an error that says *You need to install postgresql-server-dev-X.Y for building a server-side extension or libpq-dev for building a client-side application.*
|
||||
Go ahead and install `libpq-dev` with
|
||||
```sh
|
||||
$ sudo apt-get install libpq-dev
|
||||
```
|
||||
|
||||
Other packages you might need to install during this process:
|
||||
```sh
|
||||
$ sudo apt-get install pkg-config
|
||||
$ sudo apt-get install libsodium-dev
|
||||
```
|
||||
|
||||
Build the app:
|
||||
```sh
|
||||
$ stack build
|
||||
```
|
||||
|
||||
This might take a few minutes... if not hours... be prepared.
|
||||
|
||||
install yesod:
|
||||
```sh
|
||||
$ stack install yesod-bin --install-ghc
|
||||
```
|
||||
|
||||
### `Node` & `npm`
|
||||
Node and Npm are needed to compile the frontend.
|
||||
|
||||
Install:
|
||||
```sh
|
||||
$ curl -sL https://deb.nodesource.com/setup_12.x | sudo -E bash -
|
||||
$ sudo apt-get install -y nodejs
|
||||
```
|
||||
|
||||
### Add dummy data to the database
|
||||
After building the app you can prepare the database and add some dummy data:
|
||||
```sh
|
||||
$ ./db.sh -f
|
||||
```
|
||||
|
||||
## Run Uni2work
|
||||
```sh
|
||||
$ npm start
|
||||
```
|
||||
|
||||
This will compile both frontend and backend and will start Uni2work in development mode (might take a few minutes the first time). It will keep running and will watch any file changes to automatically re-compile the application if necessary.
|
||||
|
||||
If you followed the steps above you should now be able to visit http://localhost:3000 and login as one of the accounts from the Development-Logins dropdown.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Please see the [wiki](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/wikis/home) for more infos.
|
||||
6
assets/lmu/logo.svg
Normal file
6
assets/lmu/logo.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0" y="0" width="80" height="80" fill="white" stroke="currentColor" stroke-width="2" />
|
||||
<path d="M6.28906 73.7111V46.4124H8.85405V71.6958H16.7322V73.7111H6.28906Z" fill="currentColor" />
|
||||
<path d="M19.4804 73.7111V46.4124H28.0914L32.0305 67.8483H32.5801L36.6108 46.4124H45.3135V73.7111H40.0003V50.443H39.5422L34.8703 73.7111H29.5571L24.7936 50.443H24.2439V73.7111H19.4804Z" fill="currentColor" />
|
||||
<path d="M48.7945 64.0008V46.4124H58.0468V65.0085C58.0468 66.9322 59.6452 67.872 61.3446 67.8483C63.0171 67.8249 64.5508 66.749 64.5508 65.0085V46.4124H73.8031V64.0008C73.8031 66.1078 73.6565 74.2412 61.3446 74.3523C49.0327 74.4635 48.7945 66.0161 48.7945 64.0008Z" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 804 B |
19
assets/lmu/sigillum.svg
Normal file
19
assets/lmu/sigillum.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 98 KiB |
@ -1,4 +0,0 @@
|
||||
<svg width="158" height="158" viewBox="0 0 158 158" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M31 16.2494C12.1534 30.6875 0 53.4245 0 79C0 122.63 35.3695 158 79 158C122.63 158 158 122.63 158 79C158 53.4245 145.847 30.6875 127 16.2494V39.7542C135.75 50.4433 141 64.1086 141 79C141 113.242 113.242 141 79 141C44.7583 141 17 113.242 17 79C17 64.1086 22.25 50.4433 31 39.7542V16.2494Z" fill="#0A9342"/>
|
||||
<path d="M119.111 121H40.5371V107.597L79.4631 65.1392C85.0813 58.879 89.0675 53.6621 91.4218 49.4886C93.8296 45.2616 95.0335 41.0345 95.0335 36.8075C95.0335 31.2429 93.4551 26.7483 90.2982 23.3239C87.1948 19.8995 82.9945 18.1873 77.6974 18.1873C71.3836 18.1873 66.4878 20.1135 63.0099 23.966C59.5319 27.8184 57.793 33.0888 57.793 39.7771H38.2899C38.2899 32.6608 39.8951 26.2668 43.1055 20.5951C46.3693 14.8699 50.9977 10.4288 56.9904 7.27195C63.0366 4.11506 69.9925 2.53662 77.8579 2.53662C89.2013 2.53662 98.1369 5.39922 104.665 11.1244C111.246 16.7961 114.537 24.6616 114.537 34.7208C114.537 40.553 112.878 46.6795 109.561 53.1003C106.297 59.4675 100.919 66.7177 93.4283 74.8506L64.8558 105.43H119.111V121Z" fill="#0A9342"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
BIN
assets/logo.png
BIN
assets/logo.png
Binary file not shown.
|
Before Width: | Height: | Size: 6.5 KiB |
2
build.sh
2
build.sh
@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
exec -- stack build --fast --flag uniworx:-library-only --flag uniworx:dev
|
||||
exec -- stack build --fast --flag uniworx:-library-only --flag uniworx:dev $@
|
||||
echo Build task completed.
|
||||
|
||||
1
commitlint.config.js
Normal file
1
commitlint.config.js
Normal file
@ -0,0 +1 @@
|
||||
module.exports = {extends: ['@commitlint/config-conventional']}
|
||||
40
config/archive-types
Normal file
40
config/archive-types
Normal file
@ -0,0 +1,40 @@
|
||||
# Simple list of mime-types corresponding to archive-formats
|
||||
#
|
||||
# Comments are empty lines and any line for which the first non-whitespace symbol is ‘#’
|
||||
#
|
||||
# Format is a single mime-type per line (may not contain whitespace)
|
||||
#
|
||||
# Largely copied from https://en.wikipedia.org/wiki/List_of_archive_formats
|
||||
|
||||
application/x-archive
|
||||
application/x-cpio
|
||||
application/x-bcpio
|
||||
application/x-shar
|
||||
application/x-iso9660-image
|
||||
application/x-sbx
|
||||
application/x-tar
|
||||
application/x-7z-compressed
|
||||
application/x-ace-compressed
|
||||
application/x-astrotite-afa
|
||||
application/x-alz-compressed
|
||||
application/vnd.android.package-archive
|
||||
application/x-arj
|
||||
application/x-b1
|
||||
application/vnd.ms-cab-compressed
|
||||
application/x-cfs-compressed
|
||||
application/x-dar
|
||||
application/x-dgc-compressed
|
||||
application/x-apple-diskimage
|
||||
application/x-gca-compressed
|
||||
application/java-archive
|
||||
application/x-lzh
|
||||
application/x-lzx
|
||||
application/x-rar-compressed
|
||||
application/x-stuffit
|
||||
application/x-stuffitx
|
||||
application/x-gtar
|
||||
application/x-ms-wim
|
||||
application/x-xar
|
||||
application/zip
|
||||
application/x-zoo
|
||||
application/x-par2
|
||||
788
config/mimetypes
Normal file
788
config/mimetypes
Normal file
@ -0,0 +1,788 @@
|
||||
# Mapping of mime-types to file extensions
|
||||
#
|
||||
# Comments are empty lines and any line for which the first non-whitespace symbol is ‘#’
|
||||
#
|
||||
# Format is a single mime-type per line (may not contain whitespace) followed by a whitespace separated list of zero or more file extension (without leading ‘.’)
|
||||
# Any file extension may occur at most once within this file
|
||||
#
|
||||
# Extensions are compared case-insensitive (see `Data.Text.toLower`)
|
||||
|
||||
application/andrew-inset ez
|
||||
application/applixware aw
|
||||
application/atom+xml atom
|
||||
application/atomcat+xml atomcat
|
||||
application/atomsvc+xml atomsvc
|
||||
application/ccxml+xml ccxml
|
||||
application/cdmi-capability cdmia
|
||||
application/cdmi-container cdmic
|
||||
application/cdmi-domain cdmid
|
||||
application/cdmi-object cdmio
|
||||
application/cdmi-queue cdmiq
|
||||
application/cu-seeme cu
|
||||
application/davmount+xml davmount
|
||||
application/docbook+xml dbk
|
||||
application/dssc+der dssc
|
||||
application/dssc+xml xdssc
|
||||
application/ecmascript ecma
|
||||
application/emma+xml emma
|
||||
application/epub+zip epub
|
||||
application/exi exi
|
||||
application/font-tdpfr pfr
|
||||
application/font-woff woff
|
||||
application/font-woff2 woff2
|
||||
application/futuresplash spl
|
||||
application/gml+xml gml
|
||||
application/gpx+xml gpx
|
||||
application/gxf gxf
|
||||
application/hyperstudio stk
|
||||
application/inkml+xml inkml ink
|
||||
application/ipfix ipfix
|
||||
application/java-archive war jar ear
|
||||
application/java-serialized-object ser
|
||||
application/java-vm class
|
||||
application/javascript js
|
||||
application/json json
|
||||
application/jsonml+json jsonml
|
||||
application/lost+xml lostxml
|
||||
application/mac-binhex40 hqx
|
||||
application/mac-compactpro cpt
|
||||
application/mads+xml mads
|
||||
application/marc mrc
|
||||
application/marcxml+xml mrcx
|
||||
application/mathematica nb mb ma
|
||||
application/mathml+xml mathml
|
||||
application/mbox mbox
|
||||
application/mediaservercontrol+xml mscml
|
||||
application/metalink+xml metalink
|
||||
application/metalink4+xml meta4
|
||||
application/mets+xml mets
|
||||
application/mods+xml mods
|
||||
application/mp21 mp21 m21
|
||||
application/mp4 mp4s
|
||||
application/msword dot doc
|
||||
application/mxf mxf
|
||||
application/octet-stream so pkg msp msm mar lrf img elc dump dms distz dist deploy bpk bin
|
||||
application/oda oda
|
||||
application/oebps-package+xml opf
|
||||
application/ogg ogx
|
||||
application/omdoc+xml omdoc
|
||||
application/onenote onetoc2 onetoc onetmp onepkg
|
||||
application/oxps oxps
|
||||
application/patch-ops-error+xml xer
|
||||
application/pdf pdf
|
||||
application/pgp-encrypted pgp
|
||||
application/pgp-signature sig
|
||||
application/pics-rules prf
|
||||
application/pkcs10 p10
|
||||
application/pkcs7-mime p7m p7c
|
||||
application/pkcs7-signature p7s
|
||||
application/pkcs8 p8
|
||||
application/pkix-attr-cert ac
|
||||
application/pkix-cert cer
|
||||
application/pkix-crl crl
|
||||
application/pkix-pkipath pkipath
|
||||
application/pkixcmp pki
|
||||
application/pls+xml pls
|
||||
application/postscript ps eps ai
|
||||
application/prs.cww cww
|
||||
application/pskc+xml pskcxml
|
||||
application/rdf+xml rdf
|
||||
application/reginfo+xml rif
|
||||
application/relax-ng-compact-syntax rnc
|
||||
application/resource-lists+xml rl
|
||||
application/resource-lists-diff+xml rld
|
||||
application/rls-services+xml rs
|
||||
application/rpki-ghostbusters gbr
|
||||
application/rpki-manifest mft
|
||||
application/rpki-roa roa
|
||||
application/rsd+xml rsd
|
||||
application/rss+xml rss
|
||||
application/rtf rtf
|
||||
application/sbml+xml sbml
|
||||
application/scvp-cv-request scq
|
||||
application/scvp-cv-response scs
|
||||
application/scvp-vp-request spq
|
||||
application/scvp-vp-response spp
|
||||
application/sdp sdp
|
||||
application/set-payment-initiation setpay
|
||||
application/set-registration-initiation setreg
|
||||
application/shf+xml shf
|
||||
application/smil+xml smil smi
|
||||
application/sparql-query rq
|
||||
application/sparql-results+xml srx
|
||||
application/srgs gram
|
||||
application/srgs+xml grxml
|
||||
application/sru+xml sru
|
||||
application/ssdl+xml ssdl
|
||||
application/ssml+xml ssml
|
||||
application/tei+xml teicorpus tei
|
||||
application/thraud+xml tfi
|
||||
application/timestamped-data tsd
|
||||
application/vnd.3gpp.pic-bw-large plb
|
||||
application/vnd.3gpp.pic-bw-small psb
|
||||
application/vnd.3gpp.pic-bw-var pvb
|
||||
application/vnd.3gpp2.tcap tcap
|
||||
application/vnd.3m.post-it-notes pwn
|
||||
application/vnd.accpac.simply.aso aso
|
||||
application/vnd.accpac.simply.imp imp
|
||||
application/vnd.acucobol acu
|
||||
application/vnd.acucorp atc acutc
|
||||
application/vnd.adobe.air-application-installer-package+zip air
|
||||
application/vnd.adobe.formscentral.fcdt fcdt
|
||||
application/vnd.adobe.fxp fxpl fxp
|
||||
application/vnd.adobe.xdp+xml xdp
|
||||
application/vnd.adobe.xfdf xfdf
|
||||
application/vnd.ahead.space ahead
|
||||
application/vnd.airzip.filesecure.azf azf
|
||||
application/vnd.airzip.filesecure.azs azs
|
||||
application/vnd.amazon.ebook azw
|
||||
application/vnd.americandynamics.acc acc
|
||||
application/vnd.amiga.ami ami
|
||||
application/vnd.android.package-archive apk
|
||||
application/vnd.anser-web-certificate-issue-initiation cii
|
||||
application/vnd.anser-web-funds-transfer-initiation fti
|
||||
application/vnd.antix.game-component atx
|
||||
application/vnd.apple.installer+xml mpkg
|
||||
application/vnd.apple.mpegurl m3u8
|
||||
application/vnd.aristanetworks.swi swi
|
||||
application/vnd.astraea-software.iota iota
|
||||
application/vnd.audiograph aep
|
||||
application/vnd.blueice.multipass mpm
|
||||
application/vnd.bmi bmi
|
||||
application/vnd.businessobjects rep
|
||||
application/vnd.chemdraw+xml cdxml
|
||||
application/vnd.chipnuts.karaoke-mmd mmd
|
||||
application/vnd.cinderella cdy
|
||||
application/vnd.claymore cla
|
||||
application/vnd.cloanto.rp9 rp9
|
||||
application/vnd.clonk.c4group c4u c4p c4g c4f c4d
|
||||
application/vnd.cluetrust.cartomobile-config c11amc
|
||||
application/vnd.cluetrust.cartomobile-config-pkg c11amz
|
||||
application/vnd.commonspace csp
|
||||
application/vnd.contact.cmsg cdbcmsg
|
||||
application/vnd.cosmocaller cmc
|
||||
application/vnd.crick.clicker clkx
|
||||
application/vnd.crick.clicker.keyboard clkk
|
||||
application/vnd.crick.clicker.palette clkp
|
||||
application/vnd.crick.clicker.template clkt
|
||||
application/vnd.crick.clicker.wordbank clkw
|
||||
application/vnd.criticaltools.wbs+xml wbs
|
||||
application/vnd.ctc-posml pml
|
||||
application/vnd.cups-ppd ppd
|
||||
application/vnd.curl.car car
|
||||
application/vnd.curl.pcurl pcurl
|
||||
application/vnd.dart dart
|
||||
application/vnd.data-vision.rdz rdz
|
||||
application/vnd.dece.data uvvf uvvd uvf uvd
|
||||
application/vnd.dece.ttml+xml uvvt uvt
|
||||
application/vnd.dece.unspecified uvx uvvx
|
||||
application/vnd.dece.zip uvz uvvz
|
||||
application/vnd.denovo.fcselayout-link fe_launch
|
||||
application/vnd.dna dna
|
||||
application/vnd.dolby.mlp mlp
|
||||
application/vnd.dpgraph dpg
|
||||
application/vnd.dreamfactory dfac
|
||||
application/vnd.ds-keypoint kpxx
|
||||
application/vnd.dvb.ait ait
|
||||
application/vnd.dvb.service svc
|
||||
application/vnd.dynageo geo
|
||||
application/vnd.ecowin.chart mag
|
||||
application/vnd.enliven nml
|
||||
application/vnd.epson.esf esf
|
||||
application/vnd.epson.msf msf
|
||||
application/vnd.epson.quickanime qam
|
||||
application/vnd.epson.salt slt
|
||||
application/vnd.epson.ssf ssf
|
||||
application/vnd.eszigno3+xml et3 es3
|
||||
application/vnd.ezpix-album ez2
|
||||
application/vnd.ezpix-package ez3
|
||||
application/vnd.fdf fdf
|
||||
application/vnd.fdsn.mseed mseed
|
||||
application/vnd.fdsn.seed seed dataless
|
||||
application/vnd.flographit gph
|
||||
application/vnd.fluxtime.clip ftc
|
||||
application/vnd.framemaker maker frame fm book
|
||||
application/vnd.frogans.fnc fnc
|
||||
application/vnd.frogans.ltf ltf
|
||||
application/vnd.fsc.weblaunch fsc
|
||||
application/vnd.fujitsu.oasys oas
|
||||
application/vnd.fujitsu.oasys2 oa2
|
||||
application/vnd.fujitsu.oasys3 oa3
|
||||
application/vnd.fujitsu.oasysgp fg5
|
||||
application/vnd.fujitsu.oasysprs bh2
|
||||
application/vnd.fujixerox.ddd ddd
|
||||
application/vnd.fujixerox.docuworks xdw
|
||||
application/vnd.fujixerox.docuworks.binder xbd
|
||||
application/vnd.fuzzysheet fzs
|
||||
application/vnd.genomatix.tuxedo txd
|
||||
application/vnd.geogebra.file ggb
|
||||
application/vnd.geogebra.tool ggt
|
||||
application/vnd.geometry-explorer gre gex
|
||||
application/vnd.geonext gxt
|
||||
application/vnd.geoplan g2w
|
||||
application/vnd.geospace g3w
|
||||
application/vnd.gmx gmx
|
||||
application/vnd.google-earth.kml+xml kml
|
||||
application/vnd.google-earth.kmz kmz
|
||||
application/vnd.grafeq gqs gqf
|
||||
application/vnd.groove-account gac
|
||||
application/vnd.groove-help ghf
|
||||
application/vnd.groove-identity-message gim
|
||||
application/vnd.groove-injector grv
|
||||
application/vnd.groove-tool-message gtm
|
||||
application/vnd.groove-tool-template tpl
|
||||
application/vnd.groove-vcard vcg
|
||||
application/vnd.hal+xml hal
|
||||
application/vnd.handheld-entertainment+xml zmm
|
||||
application/vnd.hbci hbci
|
||||
application/vnd.hhe.lesson-player les
|
||||
application/vnd.hp-hpgl hpgl
|
||||
application/vnd.hp-hpid hpid
|
||||
application/vnd.hp-hps hps
|
||||
application/vnd.hp-jlyt jlt
|
||||
application/vnd.hp-pcl pcl
|
||||
application/vnd.hp-pclxl pclxl
|
||||
application/vnd.hydrostatix.sof-data sfd-hdstx
|
||||
application/vnd.ibm.minipay mpy
|
||||
application/vnd.ibm.modcap listafp list3820 afp
|
||||
application/vnd.ibm.rights-management irm
|
||||
application/vnd.ibm.secure-container sc
|
||||
application/vnd.iccprofile icm icc
|
||||
application/vnd.igloader igl
|
||||
application/vnd.immervision-ivp ivp
|
||||
application/vnd.immervision-ivu ivu
|
||||
application/vnd.insors.igm igm
|
||||
application/vnd.intercon.formnet xpx xpw
|
||||
application/vnd.intergeo i2g
|
||||
application/vnd.intu.qbo qbo
|
||||
application/vnd.intu.qfx qfx
|
||||
application/vnd.ipunplugged.rcprofile rcprofile
|
||||
application/vnd.irepository.package+xml irp
|
||||
application/vnd.is-xpr xpr
|
||||
application/vnd.isac.fcs fcs
|
||||
application/vnd.jam jam
|
||||
application/vnd.jcp.javame.midlet-rms rms
|
||||
application/vnd.jisp jisp
|
||||
application/vnd.joost.joda-archive joda
|
||||
application/vnd.kahootz ktz ktr
|
||||
application/vnd.kde.karbon karbon
|
||||
application/vnd.kde.kchart chrt
|
||||
application/vnd.kde.kformula kfo
|
||||
application/vnd.kde.kivio flw
|
||||
application/vnd.kde.kontour kon
|
||||
application/vnd.kde.kpresenter kpt kpr
|
||||
application/vnd.kde.kspread ksp
|
||||
application/vnd.kde.kword kwt kwd
|
||||
application/vnd.kenameaapp htke
|
||||
application/vnd.kidspiration kia
|
||||
application/vnd.kinar knp kne
|
||||
application/vnd.koan skt skp skm skd
|
||||
application/vnd.kodak-descriptor sse
|
||||
application/vnd.las.las+xml lasxml
|
||||
application/vnd.llamagraphics.life-balance.desktop lbd
|
||||
application/vnd.llamagraphics.life-balance.exchange+xml lbe
|
||||
application/vnd.lotus-1-2-3 123
|
||||
application/vnd.lotus-approach apr
|
||||
application/vnd.lotus-freelance pre
|
||||
application/vnd.lotus-notes nsf
|
||||
application/vnd.lotus-organizer org
|
||||
application/vnd.lotus-screencam scm
|
||||
application/vnd.lotus-wordpro lwp
|
||||
application/vnd.macports.portpkg portpkg
|
||||
application/vnd.mcd mcd
|
||||
application/vnd.medcalcdata mc1
|
||||
application/vnd.mediastation.cdkey cdkey
|
||||
application/vnd.mfer mwf
|
||||
application/vnd.mfmp mfm
|
||||
application/vnd.micrografx.flo flo
|
||||
application/vnd.micrografx.igx igx
|
||||
application/vnd.mif mif
|
||||
application/vnd.mobius.daf daf
|
||||
application/vnd.mobius.dis dis
|
||||
application/vnd.mobius.mbk mbk
|
||||
application/vnd.mobius.mqy mqy
|
||||
application/vnd.mobius.msl msl
|
||||
application/vnd.mobius.plc plc
|
||||
application/vnd.mobius.txf txf
|
||||
application/vnd.mophun.application mpn
|
||||
application/vnd.mophun.certificate mpc
|
||||
application/vnd.mozilla.xul+xml xul
|
||||
application/vnd.ms-artgalry cil
|
||||
application/vnd.ms-cab-compressed cab
|
||||
application/vnd.ms-excel xlw xlt xls xlm xlc xla
|
||||
application/vnd.ms-excel.addin.macroenabled.12 xlam
|
||||
application/vnd.ms-excel.sheet.binary.macroenabled.12 xlsb
|
||||
application/vnd.ms-excel.sheet.macroenabled.12 xlsm
|
||||
application/vnd.ms-excel.template.macroenabled.12 xltm
|
||||
application/vnd.ms-fontobject eot
|
||||
application/vnd.ms-htmlhelp chm
|
||||
application/vnd.ms-ims ims
|
||||
application/vnd.ms-lrm lrm
|
||||
application/vnd.ms-officetheme thmx
|
||||
application/vnd.ms-pki.seccat cat
|
||||
application/vnd.ms-pki.stl stl
|
||||
application/vnd.ms-powerpoint ppt pps pot
|
||||
application/vnd.ms-powerpoint.addin.macroenabled.12 ppam
|
||||
application/vnd.ms-powerpoint.presentation.macroenabled.12 pptm
|
||||
application/vnd.ms-powerpoint.slide.macroenabled.12 sldm
|
||||
application/vnd.ms-powerpoint.slideshow.macroenabled.12 ppsm
|
||||
application/vnd.ms-powerpoint.template.macroenabled.12 potm
|
||||
application/vnd.ms-project mpt mpp
|
||||
application/vnd.ms-word.document.macroenabled.12 docm
|
||||
application/vnd.ms-word.template.macroenabled.12 dotm
|
||||
application/vnd.ms-works wps wks wdb wcm
|
||||
application/vnd.ms-wpl wpl
|
||||
application/vnd.ms-xpsdocument xps
|
||||
application/vnd.mseq mseq
|
||||
application/vnd.musician mus
|
||||
application/vnd.muvee.style msty
|
||||
application/vnd.mynfc taglet
|
||||
application/vnd.neurolanguage.nlu nlu
|
||||
application/vnd.nitf ntf nitf
|
||||
application/vnd.noblenet-directory nnd
|
||||
application/vnd.noblenet-sealer nns
|
||||
application/vnd.noblenet-web nnw
|
||||
application/vnd.nokia.n-gage.data ngdat
|
||||
application/vnd.nokia.n-gage.symbian.install n-gage
|
||||
application/vnd.nokia.radio-preset rpst
|
||||
application/vnd.nokia.radio-presets rpss
|
||||
application/vnd.novadigm.edm edm
|
||||
application/vnd.novadigm.edx edx
|
||||
application/vnd.novadigm.ext ext
|
||||
application/vnd.oasis.opendocument.chart odc
|
||||
application/vnd.oasis.opendocument.chart-template otc
|
||||
application/vnd.oasis.opendocument.database odb
|
||||
application/vnd.oasis.opendocument.formula odf
|
||||
application/vnd.oasis.opendocument.formula-template odft
|
||||
application/vnd.oasis.opendocument.graphics odg
|
||||
application/vnd.oasis.opendocument.graphics-template otg
|
||||
application/vnd.oasis.opendocument.image odi
|
||||
application/vnd.oasis.opendocument.image-template oti
|
||||
application/vnd.oasis.opendocument.presentation odp
|
||||
application/vnd.oasis.opendocument.presentation-template otp
|
||||
application/vnd.oasis.opendocument.spreadsheet ods
|
||||
application/vnd.oasis.opendocument.spreadsheet-template ots
|
||||
application/vnd.oasis.opendocument.text odt
|
||||
application/vnd.oasis.opendocument.text-master odm
|
||||
application/vnd.oasis.opendocument.text-template ott
|
||||
application/vnd.oasis.opendocument.text-web oth
|
||||
application/vnd.olpc-sugar xo
|
||||
application/vnd.oma.dd2+xml dd2
|
||||
application/vnd.openofficeorg.extension oxt
|
||||
application/vnd.openxmlformats-officedocument.presentationml.presentation pptx
|
||||
application/vnd.openxmlformats-officedocument.presentationml.slide sldx
|
||||
application/vnd.openxmlformats-officedocument.presentationml.slideshow ppsx
|
||||
application/vnd.openxmlformats-officedocument.presentationml.template potx
|
||||
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet xlsx
|
||||
application/vnd.openxmlformats-officedocument.spreadsheetml.template xltx
|
||||
application/vnd.openxmlformats-officedocument.wordprocessingml.document docx
|
||||
application/vnd.openxmlformats-officedocument.wordprocessingml.template dotx
|
||||
application/vnd.osgeo.mapguide.package mgp
|
||||
application/vnd.osgi.dp dp
|
||||
application/vnd.osgi.subsystem esa
|
||||
application/vnd.palm pqa pdb oprc
|
||||
application/vnd.pawaafile paw
|
||||
application/vnd.pg.format str
|
||||
application/vnd.pg.osasli ei6
|
||||
application/vnd.picsel efif
|
||||
application/vnd.pmi.widget wg
|
||||
application/vnd.pocketlearn plf
|
||||
application/vnd.powerbuilder6 pbd
|
||||
application/vnd.previewsystems.box box
|
||||
application/vnd.proteus.magazine mgz
|
||||
application/vnd.publishare-delta-tree qps
|
||||
application/vnd.pvi.ptid1 ptid
|
||||
application/vnd.quark.quarkxpress qxt qxl qxd qxb qwt qwd
|
||||
application/vnd.realvnc.bed bed
|
||||
application/vnd.recordare.musicxml mxl
|
||||
application/vnd.recordare.musicxml+xml musicxml
|
||||
application/vnd.rig.cryptonote cryptonote
|
||||
application/vnd.rim.cod cod
|
||||
application/vnd.rn-realmedia rm
|
||||
application/vnd.rn-realmedia-vbr rmvb
|
||||
application/vnd.route66.link66+xml link66
|
||||
application/vnd.sailingtracker.track st
|
||||
application/vnd.seemail see
|
||||
application/vnd.sema sema
|
||||
application/vnd.semd semd
|
||||
application/vnd.semf semf
|
||||
application/vnd.shana.informed.formdata ifm
|
||||
application/vnd.shana.informed.formtemplate itp
|
||||
application/vnd.shana.informed.interchange iif
|
||||
application/vnd.shana.informed.package ipk
|
||||
application/vnd.simtech-mindmapper twds twd
|
||||
application/vnd.smaf mmf
|
||||
application/vnd.smart.teacher teacher
|
||||
application/vnd.solent.sdkm+xml sdkm sdkd
|
||||
application/vnd.spotfire.dxp dxp
|
||||
application/vnd.spotfire.sfs sfs
|
||||
application/vnd.stardivision.calc sdc
|
||||
application/vnd.stardivision.draw sda
|
||||
application/vnd.stardivision.impress sdd
|
||||
application/vnd.stardivision.math smf
|
||||
application/vnd.stardivision.writer vor sdw
|
||||
application/vnd.stardivision.writer-global sgl
|
||||
application/vnd.stepmania.package smzip
|
||||
application/vnd.stepmania.stepchart sm
|
||||
application/vnd.sun.xml.calc sxc
|
||||
application/vnd.sun.xml.calc.template stc
|
||||
application/vnd.sun.xml.draw sxd
|
||||
application/vnd.sun.xml.draw.template std
|
||||
application/vnd.sun.xml.impress sxi
|
||||
application/vnd.sun.xml.impress.template sti
|
||||
application/vnd.sun.xml.math sxm
|
||||
application/vnd.sun.xml.writer sxw
|
||||
application/vnd.sun.xml.writer.global sxg
|
||||
application/vnd.sun.xml.writer.template stw
|
||||
application/vnd.sus-calendar susp sus
|
||||
application/vnd.svd svd
|
||||
application/vnd.symbian.install sisx sis
|
||||
application/vnd.syncml+xml xsm
|
||||
application/vnd.syncml.dm+wbxml bdm
|
||||
application/vnd.syncml.dm+xml xdm
|
||||
application/vnd.tao.intent-module-archive tao
|
||||
application/vnd.tcpdump.pcap pcap dmp cap
|
||||
application/vnd.tmobile-livetv tmo
|
||||
application/vnd.trid.tpt tpt
|
||||
application/vnd.triscape.mxs mxs
|
||||
application/vnd.trueapp tra
|
||||
application/vnd.ufdl ufdl ufd
|
||||
application/vnd.uiq.theme utz
|
||||
application/vnd.umajin umj
|
||||
application/vnd.unity unityweb
|
||||
application/vnd.uoml+xml uoml
|
||||
application/vnd.vcx vcx
|
||||
application/vnd.visio vsw vst vss vsd
|
||||
application/vnd.visionary vis
|
||||
application/vnd.vsf vsf
|
||||
application/vnd.wap.wbxml wbxml
|
||||
application/vnd.wap.wmlc wmlc
|
||||
application/vnd.wap.wmlscriptc wmlsc
|
||||
application/vnd.webturbo wtb
|
||||
application/vnd.wolfram.player nbp
|
||||
application/vnd.wordperfect wpd
|
||||
application/vnd.wqd wqd
|
||||
application/vnd.wt.stf stf
|
||||
application/vnd.xara xar
|
||||
application/vnd.xfdl xfdl
|
||||
application/vnd.yamaha.hv-dic hvd
|
||||
application/vnd.yamaha.hv-script hvs
|
||||
application/vnd.yamaha.hv-voice hvp
|
||||
application/vnd.yamaha.openscoreformat osf
|
||||
application/vnd.yamaha.openscoreformat.osfpvg+xml osfpvg
|
||||
application/vnd.yamaha.smaf-audio saf
|
||||
application/vnd.yamaha.smaf-phrase spf
|
||||
application/vnd.yellowriver-custom-menu cmp
|
||||
application/vnd.zul zirz zir
|
||||
application/vnd.zzazz.deck+xml zaz
|
||||
application/voicexml+xml vxml
|
||||
application/widget wgt
|
||||
application/winhlp hlp
|
||||
application/wsdl+xml wsdl
|
||||
application/wspolicy+xml wspolicy
|
||||
application/x-7z-compressed 7z
|
||||
application/x-abiword abw
|
||||
application/x-ace-compressed ace
|
||||
application/x-apple-diskimage dmg
|
||||
application/x-authorware-bin x32 vox u32 aab
|
||||
application/x-authorware-map aam
|
||||
application/x-authorware-seg aas
|
||||
application/x-bcpio bcpio
|
||||
application/x-bittorrent torrent
|
||||
application/x-blorb blorb blb
|
||||
application/x-bzip bz2 bz
|
||||
application/x-bzip-compressed-tar tbz tar.bz2
|
||||
application/x-bzip2 boz
|
||||
application/x-cbr cbz cbt cbr cba cb7
|
||||
application/x-cdlink vcd
|
||||
application/x-cfs-compressed cfs
|
||||
application/x-chat chat
|
||||
application/x-chess-pgn pgn
|
||||
application/x-cocoa cco
|
||||
application/x-conference nsc
|
||||
application/x-cpio cpio
|
||||
application/x-csh csh
|
||||
application/x-debian-package udeb deb
|
||||
application/x-dgc-compressed dgc
|
||||
application/x-director w3d swa fgd dxr dir dcr cxt cst cct
|
||||
application/x-doom wad
|
||||
application/x-dtbncx+xml ncx
|
||||
application/x-dtbook+xml dtb
|
||||
application/x-dtbresource+xml res
|
||||
application/x-dvi dvi
|
||||
application/x-envoy evy
|
||||
application/x-eva eva
|
||||
application/x-font-bdf bdf
|
||||
application/x-font-ghostscript gsf
|
||||
application/x-font-linux-psf psf
|
||||
application/x-font-otf otf
|
||||
application/x-font-pcf pcf
|
||||
application/x-font-snf snf
|
||||
application/x-font-ttf ttf ttc
|
||||
application/x-font-type1 pfm pfb pfa afm
|
||||
application/x-freearc arc
|
||||
application/x-gca-compressed gca
|
||||
application/x-glulx ulx
|
||||
application/x-gnumeric gnumeric
|
||||
application/x-gramps-xml gramps
|
||||
application/x-gtar gtar
|
||||
application/x-gzip gz
|
||||
application/x-hdf hdf
|
||||
application/x-install-instructions install
|
||||
application/x-iso9660-image iso
|
||||
application/x-java-archive-diff jardiff
|
||||
application/x-java-jnlp-file jnlp
|
||||
application/x-latex latex
|
||||
application/x-lzh-compressed lzh lha
|
||||
application/x-makeself run
|
||||
application/x-mie mie
|
||||
application/x-mobipocket-ebook prc mobi
|
||||
application/x-ms-application application
|
||||
application/x-ms-shortcut lnk
|
||||
application/x-ms-wmd wmd
|
||||
application/x-ms-xbap xbap
|
||||
application/x-msaccess mdb
|
||||
application/x-msbinder obd
|
||||
application/x-mscardfile crd
|
||||
application/x-msclip clp
|
||||
application/x-msdownload msi exe dll com bat
|
||||
application/x-msmediaview mvb m14 m13
|
||||
application/x-msmetafile wmz wmf emz emf
|
||||
application/x-msmoney mny
|
||||
application/x-mspublisher pub
|
||||
application/x-msschedule scd
|
||||
application/x-msterminal trm
|
||||
application/x-mswrite wri
|
||||
application/x-netcdf nc cdf
|
||||
application/x-ns-proxy-autoconfig pac
|
||||
application/x-nzb nzb
|
||||
application/x-perl pm pl
|
||||
application/x-pkcs12 pfx p12
|
||||
application/x-pkcs7-certificates spc p7b
|
||||
application/x-pkcs7-certreqresp p7r
|
||||
application/x-rar-compressed rar
|
||||
application/x-redhat-package-manager rpm
|
||||
application/x-research-info-systems ris
|
||||
application/x-sea sea
|
||||
application/x-sh sh
|
||||
application/x-shar shar
|
||||
application/x-shockwave-flash swf
|
||||
application/x-silverlight-app xap
|
||||
application/x-sql sql
|
||||
application/x-stuffit sit
|
||||
application/x-stuffitx sitx
|
||||
application/x-subrip srt
|
||||
application/x-sv4cpio sv4cpio
|
||||
application/x-sv4crc sv4crc
|
||||
application/x-t3vm-image t3
|
||||
application/x-tads gam
|
||||
application/x-tar tar
|
||||
application/x-tcl tk tcl
|
||||
application/x-tex tex
|
||||
application/x-tex-tfm tfm
|
||||
application/x-texinfo texinfo texi
|
||||
application/x-tgif obj
|
||||
application/x-tgz tgz tar.gz
|
||||
application/x-ustar ustar
|
||||
application/x-wais-source src
|
||||
application/x-x509-ca-cert pem der crt
|
||||
application/x-xfig fig
|
||||
application/x-xliff+xml xlf
|
||||
application/x-xpinstall xpi
|
||||
application/x-xz xz
|
||||
application/x-zmachine z8 z7 z6 z5 z4 z3 z2 z1
|
||||
application/xaml+xml xaml
|
||||
application/xcap-diff+xml xdf
|
||||
application/xenc+xml xenc
|
||||
application/xhtml+xml xhtml xht
|
||||
application/xml xsl
|
||||
application/xml-dtd dtd
|
||||
application/xop+xml xop
|
||||
application/xproc+xml xpl
|
||||
application/xslt+xml xslt
|
||||
application/xspf+xml xspf
|
||||
application/xv+xml xvml xvm xhvml mxml
|
||||
application/yang yang
|
||||
application/yin+xml yin
|
||||
application/zip zip
|
||||
audio/adpcm adp
|
||||
audio/basic snd au
|
||||
audio/midi rmi midi mid kar
|
||||
audio/mp4 mp4a
|
||||
audio/mpeg mpga mp3 mp2a mp2 m3a m2a
|
||||
audio/ogg spx ogg oga
|
||||
audio/s3m s3m
|
||||
audio/silk sil
|
||||
audio/vnd.dece.audio uvva uva
|
||||
audio/vnd.digital-winds eol
|
||||
audio/vnd.dra dra
|
||||
audio/vnd.dts dts
|
||||
audio/vnd.dts.hd dtshd
|
||||
audio/vnd.lucent.voice lvp
|
||||
audio/vnd.ms-playready.media.pya pya
|
||||
audio/vnd.nuera.ecelp4800 ecelp4800
|
||||
audio/vnd.nuera.ecelp7470 ecelp7470
|
||||
audio/vnd.nuera.ecelp9600 ecelp9600
|
||||
audio/vnd.rip rip
|
||||
audio/webm weba
|
||||
audio/x-aac aac
|
||||
audio/x-aiff aiff aifc aif
|
||||
audio/x-caf caf
|
||||
audio/x-flac flac
|
||||
audio/x-m4a m4a
|
||||
audio/x-matroska mka
|
||||
audio/x-mpegurl m3u
|
||||
audio/x-ms-wax wax
|
||||
audio/x-ms-wma wma
|
||||
audio/x-pn-realaudio ram ra
|
||||
audio/x-pn-realaudio-plugin rmp
|
||||
audio/x-wav wav
|
||||
audio/xm xm
|
||||
chemical/x-cdx cdx
|
||||
chemical/x-cif cif
|
||||
chemical/x-cmdf cmdf
|
||||
chemical/x-cml cml
|
||||
chemical/x-csml csml
|
||||
chemical/x-xyz xyz
|
||||
image/bmp bmp
|
||||
image/cgm cgm
|
||||
image/g3fax g3
|
||||
image/gif gif
|
||||
image/ief ief
|
||||
image/jpeg jpg jpeg jpe
|
||||
image/ktx ktx
|
||||
image/png png
|
||||
image/prs.btif btif
|
||||
image/sgi sgi
|
||||
image/svg+xml svgz svg
|
||||
image/tiff tiff tif
|
||||
image/vnd.adobe.photoshop psd
|
||||
image/vnd.dece.graphic uvvi uvvg uvi uvg
|
||||
image/vnd.djvu djvu djv
|
||||
image/vnd.dwg dwg
|
||||
image/vnd.dxf dxf
|
||||
image/vnd.fastbidsheet fbs
|
||||
image/vnd.fpx fpx
|
||||
image/vnd.fst fst
|
||||
image/vnd.fujixerox.edmics-mmr mmr
|
||||
image/vnd.fujixerox.edmics-rlc rlc
|
||||
image/vnd.microsoft.icon ico
|
||||
image/vnd.ms-modi mdi
|
||||
image/vnd.ms-photo wdp
|
||||
image/vnd.net-fpx npx
|
||||
image/vnd.wap.wbmp wbmp
|
||||
image/vnd.xiff xif
|
||||
image/webp webp
|
||||
image/x-3ds 3ds
|
||||
image/x-cmu-raster ras
|
||||
image/x-cmx cmx
|
||||
image/x-freehand fhc fh7 fh5 fh4 fh
|
||||
image/x-jng jng
|
||||
image/x-mrsid-image sid
|
||||
image/x-pcx pcx
|
||||
image/x-pict pic pct
|
||||
image/x-portable-anymap pnm
|
||||
image/x-portable-bitmap pbm
|
||||
image/x-portable-graymap pgm
|
||||
image/x-portable-pixmap ppm
|
||||
image/x-rgb rgb
|
||||
image/x-tga tga
|
||||
image/x-xbitmap xbm
|
||||
image/x-xpixmap xpm
|
||||
image/x-xwindowdump xwd
|
||||
message/rfc822 mime eml
|
||||
model/iges igs iges
|
||||
model/mesh silo msh mesh
|
||||
model/vnd.collada+xml dae
|
||||
model/vnd.dwf dwf
|
||||
model/vnd.gdl gdl
|
||||
model/vnd.gtw gtw
|
||||
model/vnd.mts mts
|
||||
model/vnd.vtu vtu
|
||||
model/vrml wrl vrml
|
||||
model/x3d+binary x3dbz x3db
|
||||
model/x3d+vrml x3dvz x3dv
|
||||
model/x3d+xml x3dz x3d
|
||||
text/cache-manifest manifest appcache
|
||||
text/calendar ifb ics
|
||||
text/css less css
|
||||
text/csv csv
|
||||
text/html shtml html htm
|
||||
text/mathml mml
|
||||
text/n3 n3
|
||||
text/plain txt text log list in hs def cxx cpp conf c asc
|
||||
text/prs.lines.tag dsc
|
||||
text/richtext rtx
|
||||
text/sgml sgml sgm
|
||||
text/tab-separated-values tsv
|
||||
text/troff tr t roff ms me man
|
||||
text/turtle ttl
|
||||
text/uri-list urls uris uri
|
||||
text/vcard vcard
|
||||
text/vnd.curl curl
|
||||
text/vnd.curl.dcurl dcurl
|
||||
text/vnd.curl.mcurl mcurl
|
||||
text/vnd.curl.scurl scurl
|
||||
text/vnd.dvb.subtitle sub
|
||||
text/vnd.fly fly
|
||||
text/vnd.fmi.flexstor flx
|
||||
text/vnd.graphviz gv
|
||||
text/vnd.in3d.3dml 3dml
|
||||
text/vnd.in3d.spot spot
|
||||
text/vnd.sun.j2me.app-descriptor jad
|
||||
text/vnd.wap.wml wml
|
||||
text/vnd.wap.wmlscript wmls
|
||||
text/x-asm s asm
|
||||
text/x-c hh h dic cc
|
||||
text/x-component htc
|
||||
text/x-fortran for f90 f77 f
|
||||
text/x-java-source java
|
||||
text/x-nfo nfo
|
||||
text/x-opml opml
|
||||
text/x-pascal pas p
|
||||
text/x-setext etx
|
||||
text/x-sfv sfv
|
||||
text/x-uuencode uu
|
||||
text/x-vcalendar vcs
|
||||
text/x-vcard vcf
|
||||
text/xml xml
|
||||
video/3gpp 3gpp 3gp
|
||||
video/3gpp2 3g2
|
||||
video/h261 h261
|
||||
video/h263 h263
|
||||
video/h264 h264
|
||||
video/jpeg jpgv
|
||||
video/jpm jpm jpgm
|
||||
video/mj2 mjp2 mj2
|
||||
video/mp4 mpg4 mp4v mp4
|
||||
video/mpeg mpg mpeg mpe m2v m1v
|
||||
video/ogg ogv
|
||||
video/quicktime qt mov
|
||||
video/vnd.dece.hd uvvh uvh
|
||||
video/vnd.dece.mobile uvvm uvm
|
||||
video/vnd.dece.pd uvvp uvp
|
||||
video/vnd.dece.sd uvvs uvs
|
||||
video/vnd.dece.video uvvv uvv
|
||||
video/vnd.dvb.file dvb
|
||||
video/vnd.fvt fvt
|
||||
video/vnd.mpegurl mxu m4u
|
||||
video/vnd.ms-playready.media.pyv pyv
|
||||
video/vnd.uvvu.mp4 uvvu uvu
|
||||
video/vnd.vivo viv
|
||||
video/webm webm
|
||||
video/x-f4v f4v
|
||||
video/x-fli fli
|
||||
video/x-flv flv
|
||||
video/x-m4v m4v
|
||||
video/x-matroska mkv mks mk3d
|
||||
video/x-mng mng
|
||||
video/x-ms-asf asx asf
|
||||
video/x-ms-vob vob
|
||||
video/x-ms-wm wm
|
||||
video/x-ms-wmv wmv
|
||||
video/x-ms-wmx wmx
|
||||
video/x-ms-wvx wvx
|
||||
video/x-msvideo avi
|
||||
video/x-sgi-movie movie
|
||||
video/x-smv smv
|
||||
x-conference/x-cooltalk ice
|
||||
@ -27,7 +27,17 @@ notification-rate-limit: 3600
|
||||
notification-collate-delay: 300
|
||||
notification-expiration: 259201
|
||||
session-timeout: 7200
|
||||
maximum-content-length: 52428800
|
||||
jwt-expiration: 604800
|
||||
jwt-encoding: HS256
|
||||
maximum-content-length: "_env:MAX_UPLOAD_SIZE:134217728"
|
||||
health-check-interval:
|
||||
matching-cluster-config: "_env:HEALTHCHECK_INTERVAL_MATCHING_CLUSTER_CONFIG:600"
|
||||
http-reachable: "_env:HEALTHCHECK_INTERVAL_HTTP_REACHABLE:600"
|
||||
ldap-admins: "_env:HEALTHCHECK_INTERVAL_LDAP_ADMINS:600"
|
||||
smtp-connect: "_env:HEALTHCHECK_INTERVAL_SMTP_CONNECT:600"
|
||||
widget-memcached: "_env:HEALTHCHECK_INTERVAL_WIDGET_MEMCACHED:600"
|
||||
health-check-delay-notify: "_env:HEALTHCHECK_DELAY_NOTIFY:true"
|
||||
health-check-http: "_env:HEALTHCHECK_HTTP:true" # Can we assume, that we can reach ourselves under APPROOT via HTTP (reverse proxies or firewalls might prevent this)?
|
||||
|
||||
log-settings:
|
||||
detailed: "_env:DETAILED_LOGGING:false"
|
||||
@ -59,6 +69,8 @@ database:
|
||||
database: "_env:PGDATABASE:uniworx"
|
||||
poolsize: "_env:PGPOOLSIZE:10"
|
||||
|
||||
auto-db-migrate: '_env:AUTO_DB_MIGRATE:true'
|
||||
|
||||
ldap:
|
||||
host: "_env:LDAPHOST:"
|
||||
tls: "_env:LDAPTLS:"
|
||||
|
||||
2
db.sh
2
db.sh
@ -1,4 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Options: see /test/Database.hs (Main)
|
||||
set -e
|
||||
|
||||
stack build --fast --flag uniworx:-library-only --flag uniworx:dev
|
||||
stack exec uniworxdb -- $@
|
||||
|
||||
2
frontend/polyfills/main.js
Normal file
2
frontend/polyfills/main.js
Normal file
@ -0,0 +1,2 @@
|
||||
import './fetch';
|
||||
import './url-search-params';
|
||||
28
frontend/src/app.js
Normal file
28
frontend/src/app.js
Normal file
@ -0,0 +1,28 @@
|
||||
import { HttpClient } from './services/http-client/http-client';
|
||||
import { HtmlHelpers } from './services/html-helpers/html-helpers';
|
||||
import { I18n } from './services/i18n/i18n';
|
||||
import { UtilRegistry } from './services/util-registry/util-registry';
|
||||
import { isValidUtility } from './core/utility';
|
||||
|
||||
export class App {
|
||||
httpClient = new HttpClient();
|
||||
htmlHelpers = new HtmlHelpers();
|
||||
i18n = new I18n();
|
||||
utilRegistry = new UtilRegistry();
|
||||
|
||||
constructor() {
|
||||
this.utilRegistry.setApp(this);
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => this.utilRegistry.setupAll());
|
||||
}
|
||||
|
||||
registerUtilities(utils) {
|
||||
if (!Array.isArray(utils)) {
|
||||
throw new Error('Utils are expected to be passed as array!');
|
||||
}
|
||||
|
||||
utils.filter(isValidUtility).forEach((util) => {
|
||||
this.utilRegistry.register(util);
|
||||
});
|
||||
}
|
||||
}
|
||||
65
frontend/src/app.spec.js
Normal file
65
frontend/src/app.spec.js
Normal file
@ -0,0 +1,65 @@
|
||||
import { App } from './app';
|
||||
import { Utility } from './core/utility';
|
||||
|
||||
@Utility({ selector: 'util1' })
|
||||
class TestUtil1 { }
|
||||
|
||||
@Utility({ selector: 'util2' })
|
||||
class TestUtil2 { }
|
||||
|
||||
const TEST_UTILS = [
|
||||
TestUtil1,
|
||||
TestUtil2,
|
||||
];
|
||||
|
||||
describe('App', () => {
|
||||
let app;
|
||||
|
||||
beforeEach(() => {
|
||||
app = new App();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should setup all utlites when page is done loading', () => {
|
||||
spyOn(app.utilRegistry, 'setupAll');
|
||||
document.dispatchEvent(new Event('DOMContentLoaded'));
|
||||
expect(app.utilRegistry.setupAll).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('provides services', () => {
|
||||
it('HttpClient as httpClient', () => {
|
||||
expect(app.httpClient).toBeTruthy();
|
||||
});
|
||||
|
||||
it('HtmlHelpers as htmlHelpers', () => {
|
||||
expect(app.htmlHelpers).toBeTruthy();
|
||||
});
|
||||
|
||||
it('I18n as i18n', () => {
|
||||
expect(app.i18n).toBeTruthy();
|
||||
});
|
||||
|
||||
it('UtilRegistry as utilRegistry', () => {
|
||||
expect(app.utilRegistry).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerUtilities()', () => {
|
||||
it('should register the given utilities', () => {
|
||||
spyOn(app.utilRegistry, 'register');
|
||||
app.registerUtilities(TEST_UTILS);
|
||||
expect(app.utilRegistry.register.calls.count()).toBe(TEST_UTILS.length);
|
||||
expect(app.utilRegistry.register.calls.argsFor(0)).toEqual([TEST_UTILS[0]]);
|
||||
expect(app.utilRegistry.register.calls.argsFor(1)).toEqual([TEST_UTILS[1]]);
|
||||
});
|
||||
|
||||
it('should throw an error if not passed an array of utilities', () => {
|
||||
expect(() => {
|
||||
app.registerUtilities({});
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
22
frontend/src/core/utility.js
Normal file
22
frontend/src/core/utility.js
Normal file
@ -0,0 +1,22 @@
|
||||
export function isValidUtility(utility) {
|
||||
if (!utility) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!utility.selector) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export function Utility(metadata) {
|
||||
if (!metadata.selector) {
|
||||
throw new Error('Utility needs to have a selector!');
|
||||
}
|
||||
|
||||
return function (target) {
|
||||
target.selector = metadata.selector;
|
||||
target.isUtility = true;
|
||||
};
|
||||
};
|
||||
24
frontend/src/main.js
Normal file
24
frontend/src/main.js
Normal file
@ -0,0 +1,24 @@
|
||||
import { App } from './app';
|
||||
import { Utils } from './utils/utils';
|
||||
|
||||
export const app = new App();
|
||||
app.registerUtilities(Utils);
|
||||
|
||||
// attach the app to window to be able to get a hold of the
|
||||
// app instance from the shakespearean templates
|
||||
window.App = app;
|
||||
|
||||
// dont know where to put this currently...
|
||||
// interceptor to throw an error if an http response does not match the expected content-type
|
||||
// function contentTypeInterceptor(response, options) {
|
||||
// if (!options || !options.headers.get('Accept')) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// const contentType = response.headers.get('content-type');
|
||||
// if (!contentType.match(options.accept)) {
|
||||
// throw new Error('Server returned with '' + contentType + '' when '' + options.accept + '' was expected');
|
||||
// }
|
||||
// }
|
||||
|
||||
// window.HttpClient.addResponseInterceptor(contentTypeInterceptor);
|
||||
42
frontend/src/services/html-helpers/html-helpers.js
Normal file
42
frontend/src/services/html-helpers/html-helpers.js
Normal file
@ -0,0 +1,42 @@
|
||||
export class HtmlHelpers {
|
||||
|
||||
// `parseResponse` takes a raw HttpClient response and an options object.
|
||||
// Returns an object with `element` being an contextual fragment of the
|
||||
// HTML in the response and `ifPrefix` being the prefix that was used to
|
||||
// 'unique-ify' the ids of the received HTML.
|
||||
// Original Response IDs can optionally be kept by adding `keepIds: true`
|
||||
// to the `options` object.
|
||||
parseResponse(response, options = {}) {
|
||||
return response.text()
|
||||
.then(
|
||||
(responseText) => {
|
||||
const element = document.createElement('div');
|
||||
element.innerHTML = responseText;
|
||||
let idPrefix = '';
|
||||
if (!options.keepIds) {
|
||||
idPrefix = this._getIdPrefix();
|
||||
this._prefixIds(element, idPrefix);
|
||||
}
|
||||
return Promise.resolve({ idPrefix, element });
|
||||
},
|
||||
Promise.reject,
|
||||
).catch(console.error);
|
||||
}
|
||||
|
||||
_prefixIds(element, idPrefix) {
|
||||
const idAttrs = ['id', 'for', 'data-conditional-input', 'data-modal-trigger'];
|
||||
|
||||
idAttrs.forEach((attr) => {
|
||||
Array.from(element.querySelectorAll('[' + attr + ']')).forEach((input) => {
|
||||
const value = idPrefix + input.getAttribute(attr);
|
||||
input.setAttribute(attr, value);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_getIdPrefix() {
|
||||
// leading 'r'(andom) to overcome the fact that IDs
|
||||
// starting with a numeric value are not valid in CSS
|
||||
return 'r' + Math.floor(Math.random() * 100000) + '__';
|
||||
}
|
||||
}
|
||||
56
frontend/src/services/html-helpers/html-helpers.spec.js
Normal file
56
frontend/src/services/html-helpers/html-helpers.spec.js
Normal file
@ -0,0 +1,56 @@
|
||||
import { HtmlHelpers } from './html-helpers';
|
||||
|
||||
describe('HtmlHelpers', () => {
|
||||
let htmlHelpers;
|
||||
|
||||
beforeEach(() => {
|
||||
htmlHelpers = new HtmlHelpers();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(htmlHelpers).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('parseResponse()', () => {
|
||||
let fakeHttpResponse;
|
||||
|
||||
beforeEach(() => {
|
||||
fakeHttpResponse = {
|
||||
text: () => Promise.resolve('<div id="test-div">Test</div>'),
|
||||
};
|
||||
});
|
||||
|
||||
it('should return a promise with idPrefix and element', (done) => {
|
||||
htmlHelpers.parseResponse(fakeHttpResponse).then(result => {
|
||||
expect(result.idPrefix).toBeDefined();
|
||||
expect(result.element).toBeDefined();
|
||||
expect(result.element.textContent).toMatch('Test');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should nudge IDs', (done) => {
|
||||
htmlHelpers.parseResponse(fakeHttpResponse).then(result => {
|
||||
expect(result.idPrefix).toBeDefined();
|
||||
expect(result.element).toBeDefined();
|
||||
const elementWithOrigId = result.element.querySelector('#test-div');
|
||||
expect(elementWithOrigId).toBeFalsy();
|
||||
const elementWithNudgedId = result.element.querySelector('#' + result.idPrefix + 'test-div');
|
||||
expect(elementWithNudgedId).toBeTruthy();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not nudge IDs with option "keepIds"', (done) => {
|
||||
const options = { keepIds: true };
|
||||
|
||||
htmlHelpers.parseResponse(fakeHttpResponse, options).then(result => {
|
||||
expect(result.idPrefix).toBe('');
|
||||
expect(result.element).toBeDefined();
|
||||
const elementWithOrigId = result.element.querySelector('#test-div');
|
||||
expect(elementWithOrigId).toBeTruthy();
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
41
frontend/src/services/http-client/http-client.js
Normal file
41
frontend/src/services/http-client/http-client.js
Normal file
@ -0,0 +1,41 @@
|
||||
export class HttpClient {
|
||||
|
||||
static ACCEPT = {
|
||||
TEXT_HTML: 'text/html',
|
||||
JSON: 'application/json',
|
||||
};
|
||||
|
||||
_responseInterceptors = [];
|
||||
|
||||
addResponseInterceptor(interceptor) {
|
||||
if (typeof interceptor === 'function') {
|
||||
this._responseInterceptors.push(interceptor);
|
||||
}
|
||||
}
|
||||
|
||||
get(args) {
|
||||
args.method = 'GET';
|
||||
return this._fetch(args);
|
||||
}
|
||||
|
||||
post(args) {
|
||||
args.method = 'POST';
|
||||
return this._fetch(args);
|
||||
}
|
||||
|
||||
_fetch(options) {
|
||||
const requestOptions = {
|
||||
credentials: 'same-origin',
|
||||
...options,
|
||||
};
|
||||
|
||||
return fetch(options.url, requestOptions)
|
||||
.then(
|
||||
(response) => {
|
||||
this._responseInterceptors.forEach((interceptor) => interceptor(response, options));
|
||||
return Promise.resolve(response);
|
||||
},
|
||||
Promise.reject,
|
||||
).catch(console.error);
|
||||
}
|
||||
}
|
||||
116
frontend/src/services/http-client/http-client.spec.js
Normal file
116
frontend/src/services/http-client/http-client.spec.js
Normal file
@ -0,0 +1,116 @@
|
||||
import { HttpClient } from './http-client';
|
||||
|
||||
const TEST_URL = 'http://example.com';
|
||||
const FAKE_RESPONSE = {
|
||||
data: 'data',
|
||||
};
|
||||
|
||||
describe('HttpClient', () => {
|
||||
let httpClient;
|
||||
|
||||
beforeEach(() => {
|
||||
httpClient = new HttpClient();
|
||||
|
||||
// setup and spy on fake fetch API
|
||||
spyOn(window, 'fetch').and.returnValue(Promise.resolve(FAKE_RESPONSE));
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(httpClient).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('get()', () => {
|
||||
let params;
|
||||
|
||||
beforeEach(() => {
|
||||
params = {
|
||||
url: TEST_URL,
|
||||
};
|
||||
});
|
||||
|
||||
it('should GET the given url', () => {
|
||||
httpClient.get(params);
|
||||
expect(window.fetch).toHaveBeenCalledWith(params.url, jasmine.objectContaining({ method: 'GET' }));
|
||||
});
|
||||
|
||||
it('should return a promise', (done) => {
|
||||
const result = httpClient.get(params);
|
||||
result.then((response) => {
|
||||
expect(response).toEqual(FAKE_RESPONSE);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('post()', () => {
|
||||
let params;
|
||||
|
||||
beforeEach(() => {
|
||||
params = {
|
||||
url: TEST_URL,
|
||||
};
|
||||
});
|
||||
|
||||
it('should POST the given url', () => {
|
||||
httpClient.post(params);
|
||||
expect(window.fetch).toHaveBeenCalledWith(params.url, jasmine.objectContaining({ method: 'POST' }));
|
||||
});
|
||||
|
||||
it('should return a promise', (done) => {
|
||||
const result = httpClient.post(params);
|
||||
result.then((response) => {
|
||||
expect(response).toEqual(FAKE_RESPONSE);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Response Interceptors', () => {
|
||||
it('can be added', () => {
|
||||
const interceptor = () => {};
|
||||
expect(httpClient._responseInterceptors.length).toBe(0);
|
||||
httpClient.addResponseInterceptor(interceptor);
|
||||
expect(httpClient._responseInterceptors.length).toBe(1);
|
||||
httpClient.addResponseInterceptor(interceptor);
|
||||
expect(httpClient._responseInterceptors.length).toBe(2);
|
||||
});
|
||||
|
||||
describe('get called', () => {
|
||||
let intercepted1;
|
||||
let intercepted2;
|
||||
const interceptors = {
|
||||
interceptor1: () => intercepted1 = true,
|
||||
interceptor2: () => intercepted2 = true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
intercepted1 = false;
|
||||
intercepted2 = false;
|
||||
spyOn(interceptors, 'interceptor1').and.callThrough();
|
||||
spyOn(interceptors, 'interceptor2').and.callThrough();
|
||||
httpClient.addResponseInterceptor(interceptors.interceptor1);
|
||||
httpClient.addResponseInterceptor(interceptors.interceptor2);
|
||||
});
|
||||
|
||||
it('for GET requests', (done) => {
|
||||
httpClient.get({ url: TEST_URL }).then(() => {
|
||||
expect(intercepted1).toBeTruthy();
|
||||
expect(intercepted2).toBeTruthy();
|
||||
expect(interceptors.interceptor1).toHaveBeenCalledWith(FAKE_RESPONSE, jasmine.any(Object));
|
||||
expect(interceptors.interceptor2).toHaveBeenCalledWith(FAKE_RESPONSE, jasmine.any(Object));
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('for POST requests', (done) => {
|
||||
httpClient.post({ url: TEST_URL }).then(() => {
|
||||
expect(intercepted1).toBeTruthy();
|
||||
expect(intercepted2).toBeTruthy();
|
||||
expect(interceptors.interceptor1).toHaveBeenCalledWith(FAKE_RESPONSE, jasmine.any(Object));
|
||||
expect(interceptors.interceptor2).toHaveBeenCalledWith(FAKE_RESPONSE, jasmine.any(Object));
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
32
frontend/src/services/i18n/i18n.js
Normal file
32
frontend/src/services/i18n/i18n.js
Normal file
@ -0,0 +1,32 @@
|
||||
/**
|
||||
* I18n
|
||||
*
|
||||
* This module stores and serves translated strings, according to the users language settings.
|
||||
*
|
||||
* Translations are stored in /messages/frontend/*.msg.
|
||||
*
|
||||
* To make additions to any of these files accessible to JavaScrip Utilities
|
||||
* you need to add them to the respective *.msg file and to the list of FrontendMessages
|
||||
* in /src/Utils/Frontend/I18n.hs.
|
||||
*
|
||||
*/
|
||||
|
||||
export class I18n {
|
||||
|
||||
translations = {};
|
||||
|
||||
add(id, translation) {
|
||||
this.translations[id] = translation;
|
||||
}
|
||||
|
||||
addMany(manyTranslations) {
|
||||
Object.keys(manyTranslations).forEach((key) => this.add(key, manyTranslations[key]));
|
||||
}
|
||||
|
||||
get(id) {
|
||||
if (!this.translations[id]) {
|
||||
throw new Error('I18N Error: Translation missing for »' + id + '«!');
|
||||
}
|
||||
return this.translations[id];
|
||||
}
|
||||
}
|
||||
51
frontend/src/services/i18n/i18n.spec.js
Normal file
51
frontend/src/services/i18n/i18n.spec.js
Normal file
@ -0,0 +1,51 @@
|
||||
import { I18n } from './i18n';
|
||||
|
||||
describe('I18n', () => {
|
||||
let i18n;
|
||||
|
||||
beforeEach(() => {
|
||||
i18n = new I18n();
|
||||
});
|
||||
|
||||
// helper function
|
||||
function expectTranslation(id, value) {
|
||||
expect(i18n.translations[id]).toMatch(value);
|
||||
}
|
||||
|
||||
it('should create', () => {
|
||||
expect(i18n).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('add()', () => {
|
||||
it('should add the translation', () => {
|
||||
i18n.add('id1', 'translated-id1');
|
||||
expectTranslation('id1', 'translated-id1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addMany()', () => {
|
||||
it('should add many translations', () => {
|
||||
i18n.addMany({
|
||||
id1: 'translated-id1',
|
||||
id2: 'translated-id2',
|
||||
id3: 'translated-id3',
|
||||
});
|
||||
expectTranslation('id1', 'translated-id1');
|
||||
expectTranslation('id2', 'translated-id2');
|
||||
expectTranslation('id3', 'translated-id3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('get()', () => {
|
||||
it('should return stored translations', () => {
|
||||
i18n.translations.id1 = 'something';
|
||||
expect(i18n.get('id1')).toMatch('something');
|
||||
});
|
||||
|
||||
it('should throw error if translation is missing', () => {
|
||||
expect(() => {
|
||||
i18n.get('id1');
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
123
frontend/src/services/util-registry/util-registry.js
Normal file
123
frontend/src/services/util-registry/util-registry.js
Normal file
@ -0,0 +1,123 @@
|
||||
const DEBUG_MODE = /localhost/.test(window.location.href) && 0;
|
||||
|
||||
export class UtilRegistry {
|
||||
|
||||
_registeredUtils = [];
|
||||
_activeUtilInstances = [];
|
||||
_appInstance;
|
||||
|
||||
/**
|
||||
* function registerUtil
|
||||
*
|
||||
* utils need to have at least these properties:
|
||||
* name: string | utils name, e.g. 'example'
|
||||
* selector: string | utils selector, e.g. '[uw-example]'
|
||||
* setup: Function | utils setup function, see below
|
||||
*
|
||||
* setup function must return instance object with at least these properties:
|
||||
* name: string | utils name
|
||||
* element: HTMLElement | element the util is applied to
|
||||
* destroy: Function | function to destroy the util and remove any listeners
|
||||
*
|
||||
* @param util Object Utility that should be added to the registry
|
||||
*/
|
||||
register(util) {
|
||||
if (DEBUG_MODE > 2) {
|
||||
console.log('registering util "' + util.name + '"');
|
||||
console.log({ util });
|
||||
}
|
||||
this._registeredUtils.push(util);
|
||||
}
|
||||
|
||||
deregister(name, destroy) {
|
||||
const utilIndex = this._findUtilIndex(name);
|
||||
|
||||
if (utilIndex >= 0) {
|
||||
if (destroy === true) {
|
||||
this._destroyUtilInstances(name);
|
||||
}
|
||||
|
||||
this._registeredUtils.splice(utilIndex, 1);
|
||||
}
|
||||
}
|
||||
|
||||
setApp(appInstance) {
|
||||
this._appInstance = appInstance;
|
||||
}
|
||||
|
||||
setupAll(scope) {
|
||||
if (DEBUG_MODE > 1) {
|
||||
console.info('registered js utilities:');
|
||||
console.table(this._registeredUtils);
|
||||
}
|
||||
|
||||
this._registeredUtils.forEach((util) => this.setup(util, scope));
|
||||
}
|
||||
|
||||
setup(util, scope = document.body) {
|
||||
if (DEBUG_MODE > 2) {
|
||||
console.log('setting up util', { util });
|
||||
}
|
||||
|
||||
let instances = [];
|
||||
|
||||
if (util) {
|
||||
const elements = this._findUtilElements(util, scope);
|
||||
|
||||
elements.forEach((element) => {
|
||||
let utilInstance = null;
|
||||
|
||||
try {
|
||||
utilInstance = new util(element, this._appInstance);
|
||||
} catch(err) {
|
||||
if (DEBUG_MODE > 0) {
|
||||
console.warn('Error while trying to initialize a utility!', { util , element, err });
|
||||
}
|
||||
}
|
||||
|
||||
if (utilInstance) {
|
||||
if (DEBUG_MODE > 2) {
|
||||
console.info('Got utility instance for utility "' + util.name + '"', { utilInstance });
|
||||
}
|
||||
|
||||
instances.push(utilInstance);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this._activeUtilInstances.push(...instances);
|
||||
return instances;
|
||||
}
|
||||
|
||||
find(name) {
|
||||
return this._registeredUtils.find((util) => util.name === name);
|
||||
}
|
||||
|
||||
_findUtilElements(util, scope) {
|
||||
if (scope && scope.matches(util.selector)) {
|
||||
return [scope];
|
||||
}
|
||||
return Array.from(scope.querySelectorAll(util.selector));
|
||||
}
|
||||
|
||||
_findUtilIndex(name) {
|
||||
return this._registeredUtils.findIndex((util) => util.name === name);
|
||||
}
|
||||
|
||||
_destroyUtilInstances(name) {
|
||||
this._activeUtilInstances
|
||||
.map((util, index) => ({
|
||||
util: util,
|
||||
index: index,
|
||||
}))
|
||||
.filter((activeUtil) => activeUtil.util.name === name)
|
||||
.forEach((activeUtil) => {
|
||||
// destroy util instance
|
||||
activeUtil.util.destroy();
|
||||
delete this._activeUtilInstances[activeUtil.index];
|
||||
});
|
||||
|
||||
// get rid of now empty array slots
|
||||
this._activeUtilInstances = this._activeUtilInstances.filter((util) => !!util);
|
||||
}
|
||||
}
|
||||
146
frontend/src/services/util-registry/util-registry.spec.js
Normal file
146
frontend/src/services/util-registry/util-registry.spec.js
Normal file
@ -0,0 +1,146 @@
|
||||
import { UtilRegistry } from './util-registry';
|
||||
import { Utility } from '../../core/utility';
|
||||
|
||||
describe('UtilRegistry', () => {
|
||||
let utilRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
utilRegistry = new UtilRegistry();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(utilRegistry).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('register()', () => {
|
||||
it('should allow to add utilities', () => {
|
||||
let foundUtil = utilRegistry.find(TestUtil1.name);
|
||||
expect(foundUtil).toBeFalsy();
|
||||
|
||||
utilRegistry.register(TestUtil1);
|
||||
|
||||
foundUtil = utilRegistry.find(TestUtil1.name);
|
||||
expect(foundUtil).toEqual(TestUtil1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deregister()', () => {
|
||||
it('should remove util', () => {
|
||||
// register util
|
||||
utilRegistry.register(TestUtil1);
|
||||
let foundUtil = utilRegistry.find(TestUtil1.name);
|
||||
expect(foundUtil).toBeTruthy();
|
||||
|
||||
// deregister util
|
||||
utilRegistry.deregister(TestUtil1.name);
|
||||
foundUtil = utilRegistry.find(TestUtil1.name);
|
||||
expect(foundUtil).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should destroy util instances if requested', () => {
|
||||
pending('TBD');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setup()', () => {
|
||||
|
||||
it('should catch errors thrown by the utility', () => {
|
||||
expect(() => {
|
||||
utilRegistry.setup(ThrowingUtil);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
describe('scope has no matching elements', () => {
|
||||
it('should not construct an instance', () => {
|
||||
const scope = document.createElement('div');
|
||||
const instances = utilRegistry.setup(TestUtil1, scope);
|
||||
expect(instances.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should use fallback scope', () => {
|
||||
const instances = utilRegistry.setup(TestUtil1);
|
||||
expect(instances.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scope has matching elements', () => {
|
||||
let testScope;
|
||||
let testElement1;
|
||||
let testElement2;
|
||||
|
||||
beforeEach(() => {
|
||||
testScope = document.createElement('div');
|
||||
testElement1 = document.createElement('div');
|
||||
testElement2 = document.createElement('div');
|
||||
testElement1.classList.add('util1');
|
||||
testElement2.classList.add('util1');
|
||||
testScope.appendChild(testElement1);
|
||||
testScope.appendChild(testElement2);
|
||||
});
|
||||
|
||||
it('should construct a utility instance', () => {
|
||||
const setupUtilities = utilRegistry.setup(TestUtil1, testScope);
|
||||
expect(setupUtilities).toBeTruthy();
|
||||
expect(setupUtilities[0]).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should construct an instance for each matching element', () => {
|
||||
const setupUtilities = utilRegistry.setup(TestUtil1, testScope);
|
||||
expect(setupUtilities).toBeTruthy();
|
||||
expect(setupUtilities[0].element).toBe(testElement1);
|
||||
expect(setupUtilities[1].element).toBe(testElement2);
|
||||
});
|
||||
|
||||
it('should pass the app instance', () => {
|
||||
const fakeApp = { };
|
||||
utilRegistry.setApp(fakeApp);
|
||||
|
||||
const setupUtilities = utilRegistry.setup(TestUtil1, testScope);
|
||||
expect(setupUtilities).toBeTruthy();
|
||||
expect(setupUtilities[0].app).toBe(fakeApp);
|
||||
expect(setupUtilities[1].app).toBe(fakeApp);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('setupAll()', () => {
|
||||
it('should setup all the utilities', () => {
|
||||
spyOn(utilRegistry, 'setup');
|
||||
utilRegistry.register(TestUtil1);
|
||||
utilRegistry.register(TestUtil2);
|
||||
utilRegistry.setupAll();
|
||||
|
||||
expect(utilRegistry.setup.calls.count()).toBe(2);
|
||||
expect(utilRegistry.setup.calls.argsFor(0)).toEqual([TestUtil1, undefined]);
|
||||
expect(utilRegistry.setup.calls.argsFor(1)).toEqual([TestUtil2, undefined]);
|
||||
});
|
||||
|
||||
it('should pass the given scope', () => {
|
||||
spyOn(utilRegistry, 'setup');
|
||||
utilRegistry.register(TestUtil1);
|
||||
const scope = document.createElement('div');
|
||||
utilRegistry.setupAll(scope);
|
||||
|
||||
expect(utilRegistry.setup).toHaveBeenCalledWith(TestUtil1, scope);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// test utilities
|
||||
@Utility({ selector: '.util1' })
|
||||
class TestUtil1 {
|
||||
constructor(element, app) {
|
||||
this.element = element;
|
||||
this.app = app;
|
||||
}
|
||||
}
|
||||
|
||||
@Utility({ selector: '#util2' })
|
||||
class TestUtil2 { }
|
||||
|
||||
@Utility({ selector: '#throws' })
|
||||
class ThrowingUtil {
|
||||
constructor() {
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
165
frontend/src/utils/alerts/alerts.js
Normal file
165
frontend/src/utils/alerts/alerts.js
Normal file
@ -0,0 +1,165 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
import './alerts.scss';
|
||||
|
||||
const ALERTS_INITIALIZED_CLASS = 'alerts--initialized';
|
||||
const ALERTS_ELEVATED_CLASS = 'alerts--elevated';
|
||||
const ALERTS_TOGGLER_CLASS = 'alerts__toggler';
|
||||
const ALERTS_TOGGLER_VISIBLE_CLASS = 'alerts__toggler--visible';
|
||||
const ALERTS_TOGGLER_APPEAR_DELAY = 120;
|
||||
|
||||
const ALERT_CLASS = 'alert';
|
||||
const ALERT_INITIALIZED_CLASS = 'alert--initialized';
|
||||
const ALERT_CLOSER_CLASS = 'alert__closer';
|
||||
const ALERT_ICON_CLASS = 'alert__icon';
|
||||
const ALERT_CONTENT_CLASS = 'alert__content';
|
||||
const ALERT_INVISIBLE_CLASS = 'alert--invisible';
|
||||
const ALERT_AUTO_HIDE_DELAY = 10;
|
||||
const ALERT_AUTOCLOSING_MATCHER = '.alert-info, .alert-success';
|
||||
|
||||
@Utility({
|
||||
selector: '[uw-alerts]',
|
||||
})
|
||||
export class Alerts {
|
||||
_togglerCheckRequested = false;
|
||||
_togglerElement;
|
||||
_alertElements;
|
||||
|
||||
_element;
|
||||
_app;
|
||||
|
||||
constructor(element, app) {
|
||||
if (!element) {
|
||||
throw new Error('Alerts util has to be called with an element!');
|
||||
}
|
||||
|
||||
this._element = element;
|
||||
this._app = app;
|
||||
|
||||
if (this._element.classList.contains(ALERTS_INITIALIZED_CLASS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._togglerElement = this._element.querySelector('.' + ALERTS_TOGGLER_CLASS);
|
||||
this._alertElements = this._gatherAlertElements();
|
||||
|
||||
if (this._togglerElement) {
|
||||
this._initToggler();
|
||||
}
|
||||
|
||||
this._initAlerts();
|
||||
|
||||
// register http client interceptor to filter out Alerts Header
|
||||
this._setupHttpInterceptor();
|
||||
|
||||
// mark initialized
|
||||
this._element.classList.add(ALERTS_INITIALIZED_CLASS);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
console.log('TBD: Destroy Alert');
|
||||
}
|
||||
|
||||
_gatherAlertElements() {
|
||||
return Array.from(this._element.querySelectorAll('.' + ALERT_CLASS)).filter(function(alert) {
|
||||
return !alert.classList.contains(ALERT_INITIALIZED_CLASS);
|
||||
});
|
||||
}
|
||||
|
||||
_initToggler() {
|
||||
this._togglerElement.addEventListener('click', () => {
|
||||
this._alertElements.forEach((alertEl) => this._toggleAlert(alertEl, true));
|
||||
this._togglerElement.classList.remove(ALERTS_TOGGLER_VISIBLE_CLASS);
|
||||
});
|
||||
}
|
||||
|
||||
_initAlerts() {
|
||||
this._alertElements.forEach((alert) => this._initAlert(alert));
|
||||
}
|
||||
|
||||
_initAlert(alertElement) {
|
||||
let autoHideDelay = ALERT_AUTO_HIDE_DELAY;
|
||||
if (alertElement.dataset.decay) {
|
||||
autoHideDelay = parseInt(alertElement.dataset.decay, 10);
|
||||
}
|
||||
|
||||
const closeEl = alertElement.querySelector('.' + ALERT_CLOSER_CLASS);
|
||||
closeEl.addEventListener('click', () => {
|
||||
this._toggleAlert(alertElement);
|
||||
});
|
||||
|
||||
if (autoHideDelay > 0 && alertElement.matches(ALERT_AUTOCLOSING_MATCHER)) {
|
||||
window.setTimeout(() => this._toggleAlert(alertElement), autoHideDelay * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
_toggleAlert(alertEl, visible) {
|
||||
alertEl.classList.toggle(ALERT_INVISIBLE_CLASS, !visible);
|
||||
this._checkToggler();
|
||||
}
|
||||
|
||||
_checkToggler() {
|
||||
if (this._togglerCheckRequested) {
|
||||
return;
|
||||
}
|
||||
|
||||
const alertsHidden = this._alertElements.reduce(function(acc, alert) {
|
||||
return acc && alert.classList.contains(ALERT_INVISIBLE_CLASS);
|
||||
}, true);
|
||||
|
||||
window.setTimeout(() => {
|
||||
this._togglerElement.classList.toggle(ALERTS_TOGGLER_VISIBLE_CLASS, alertsHidden);
|
||||
this._togglerCheckRequested = false;
|
||||
}, ALERTS_TOGGLER_APPEAR_DELAY);
|
||||
}
|
||||
|
||||
_setupHttpInterceptor() {
|
||||
this._app.httpClient.addResponseInterceptor(this._responseInterceptor.bind(this));
|
||||
}
|
||||
|
||||
_elevateAlerts() {
|
||||
this._element.classList.add(ALERTS_ELEVATED_CLASS);
|
||||
}
|
||||
|
||||
_responseInterceptor = (response) => {
|
||||
let alerts;
|
||||
for (const header of response.headers) {
|
||||
if (header[0] === 'alerts') {
|
||||
const decodedHeader = decodeURIComponent(header[1]);
|
||||
alerts = JSON.parse(decodedHeader);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (alerts) {
|
||||
alerts.forEach((alert) => {
|
||||
const alertElement = this._createAlertElement(alert.status, alert.content);
|
||||
this._element.appendChild(alertElement);
|
||||
this._alertElements.push(alertElement);
|
||||
this._initAlert(alertElement);
|
||||
});
|
||||
|
||||
this._elevateAlerts();
|
||||
}
|
||||
}
|
||||
|
||||
_createAlertElement(type, content) {
|
||||
const alertElement = document.createElement('div');
|
||||
alertElement.classList.add(ALERT_CLASS, 'alert-' + type);
|
||||
|
||||
const alertCloser = document.createElement('div');
|
||||
alertCloser.classList.add(ALERT_CLOSER_CLASS);
|
||||
|
||||
const alertIcon = document.createElement('div');
|
||||
alertIcon.classList.add(ALERT_ICON_CLASS);
|
||||
|
||||
const alertContent = document.createElement('div');
|
||||
alertContent.classList.add(ALERT_CONTENT_CLASS);
|
||||
alertContent.innerHTML = content;
|
||||
|
||||
alertElement.appendChild(alertCloser);
|
||||
alertElement.appendChild(alertIcon);
|
||||
alertElement.appendChild(alertContent);
|
||||
|
||||
return alertElement;
|
||||
}
|
||||
}
|
||||
35
frontend/src/utils/alerts/alerts.md
Normal file
35
frontend/src/utils/alerts/alerts.md
Normal file
@ -0,0 +1,35 @@
|
||||
# Alerts
|
||||
|
||||
Makes alerts interactive.
|
||||
|
||||
## Attribute: `uw-alerts`
|
||||
|
||||
## Types of alerts:
|
||||
- `default`\
|
||||
Regular Info Alert
|
||||
Disappears automatically after 30 seconds
|
||||
Disappears after x seconds if explicitly specified via data-decay='x'
|
||||
Can be told not to disappear with data-decay='0'
|
||||
|
||||
- `success`\
|
||||
Currently no special visual appearance
|
||||
Disappears automatically after 30 seconds
|
||||
|
||||
- `warning`\
|
||||
Will be coloured warning-orange regardless of user's selected theme
|
||||
Does not disappear
|
||||
|
||||
- `error`\
|
||||
Will be coloured error-red regardless of user's selected theme
|
||||
Does not disappear
|
||||
|
||||
## Example usage:
|
||||
```html
|
||||
<div .alerts uw-alerts>
|
||||
<div .alerts__toggler>
|
||||
<div .alert.alert-info>
|
||||
<div .alert__closer>
|
||||
<div .alert__icon>
|
||||
<div .alert__content>
|
||||
This is some information
|
||||
```
|
||||
@ -1,23 +1,3 @@
|
||||
/* ALERTS */
|
||||
/**
|
||||
.alert
|
||||
Regular Info Alert
|
||||
Disappears automatically after 30 seconds
|
||||
Disappears after x seconds if explicitly specified via data-decay='x'
|
||||
Can be told not to disappear with data-decay='0'
|
||||
|
||||
.alert-success
|
||||
Disappears automatically after 30 seconds
|
||||
|
||||
.alert-warning
|
||||
Does not disappear
|
||||
Orange regardless of user's selected theme
|
||||
|
||||
.alert-error
|
||||
Does not disappear
|
||||
Red regardless of user's selected theme
|
||||
|
||||
*/
|
||||
.alerts {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
@ -40,7 +20,7 @@
|
||||
&::before {
|
||||
content: '\f077';
|
||||
position: absolute;
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
left: 50%;
|
||||
top: 0;
|
||||
height: 30px;
|
||||
@ -54,6 +34,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.alerts--elevated {
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.alerts__toggler--visible {
|
||||
top: -40px;
|
||||
opacity: 1;
|
||||
@ -142,7 +126,7 @@
|
||||
&::before {
|
||||
content: '\f05a';
|
||||
position: absolute;
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
font-size: 24px;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
@ -180,7 +164,7 @@
|
||||
&::before {
|
||||
content: '\f00d';
|
||||
position: absolute;
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
27
frontend/src/utils/alerts/alerts.spec.js
Normal file
27
frontend/src/utils/alerts/alerts.spec.js
Normal file
@ -0,0 +1,27 @@
|
||||
import { Alerts } from './alerts';
|
||||
|
||||
const MOCK_APP = {
|
||||
httpClient: {
|
||||
addResponseInterceptor: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
describe('Alerts', () => {
|
||||
|
||||
let alerts;
|
||||
|
||||
beforeEach(() => {
|
||||
const element = document.createElement('div');
|
||||
alerts = new Alerts(element, MOCK_APP);
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(alerts).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should throw if called without an element', () => {
|
||||
expect(() => {
|
||||
new Alerts();
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
82
frontend/src/utils/asidenav/asidenav.js
Normal file
82
frontend/src/utils/asidenav/asidenav.js
Normal file
@ -0,0 +1,82 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
import './asidenav.scss';
|
||||
|
||||
const FAVORITES_BTN_CLASS = 'navbar__list-item--favorite';
|
||||
const FAVORITES_BTN_ACTIVE_CLASS = 'navbar__list-item--active';
|
||||
const ASIDENAV_INITIALIZED_CLASS = 'asidenav--initialized';
|
||||
const ASIDENAV_EXPANDED_CLASS = 'main__aside--expanded';
|
||||
const ASIDENAV_LIST_ITEM_CLASS = 'asidenav__list-item';
|
||||
const ASIDENAV_SUBMENU_CLASS = 'asidenav__nested-list-wrapper';
|
||||
|
||||
@Utility({
|
||||
selector: '[uw-asidenav]',
|
||||
})
|
||||
export class Asidenav {
|
||||
|
||||
_element;
|
||||
_asidenavSubmenus;
|
||||
|
||||
constructor(element) {
|
||||
if (!element) {
|
||||
throw new Error('Asidenav utility cannot be setup without an element!');
|
||||
}
|
||||
|
||||
this._element = element;
|
||||
|
||||
if (this._element.classList.contains(ASIDENAV_INITIALIZED_CLASS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._initFavoritesButton();
|
||||
this._initAsidenavSubmenus();
|
||||
|
||||
// mark initialized
|
||||
this._element.classList.add(ASIDENAV_INITIALIZED_CLASS);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._asidenavSubmenus.forEach((union) => {
|
||||
union.listItem.removeEventListener(union.hoverHandler);
|
||||
});
|
||||
}
|
||||
|
||||
_initFavoritesButton() {
|
||||
const favoritesBtn = document.querySelector('.' + FAVORITES_BTN_CLASS);
|
||||
if (favoritesBtn) {
|
||||
favoritesBtn.addEventListener('click', (event) => {
|
||||
favoritesBtn.classList.toggle(FAVORITES_BTN_ACTIVE_CLASS);
|
||||
this._element.classList.toggle(ASIDENAV_EXPANDED_CLASS);
|
||||
event.preventDefault();
|
||||
}, true);
|
||||
}
|
||||
}
|
||||
|
||||
_initAsidenavSubmenus() {
|
||||
this._asidenavSubmenus = Array.from(this._element.querySelectorAll('.' + ASIDENAV_LIST_ITEM_CLASS))
|
||||
.map(function(listItem) {
|
||||
const submenu = listItem.querySelector('.' + ASIDENAV_SUBMENU_CLASS);
|
||||
return { listItem, submenu };
|
||||
}).filter(function(union) {
|
||||
return union.submenu !== null;
|
||||
});
|
||||
|
||||
this._asidenavSubmenus.forEach((union) => {
|
||||
union.hoverHandler = this._createMouseoverHandler(union);
|
||||
union.listItem.addEventListener('mouseover', union.hoverHandler);
|
||||
});
|
||||
}
|
||||
|
||||
_createMouseoverHandler(union) {
|
||||
return () => {
|
||||
const rectListItem = union.listItem.getBoundingClientRect();
|
||||
const rectSubMenu = union.submenu.getBoundingClientRect();
|
||||
|
||||
union.submenu.style.left = (rectListItem.left + rectListItem.width) + 'px';
|
||||
if (window.innerHeight - rectListItem.top < rectSubMenu.height) {
|
||||
union.submenu.style.top = (rectListItem.top + rectListItem.height - rectSubMenu.height) + 'px';
|
||||
} else {
|
||||
union.submenu.style.top = rectListItem.top + 'px';
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
21
frontend/src/utils/asidenav/asidenav.md
Normal file
21
frontend/src/utils/asidenav/asidenav.md
Normal file
@ -0,0 +1,21 @@
|
||||
# Asidenav
|
||||
|
||||
Correctly positions hovered asidenav submenus and handles the favorites button on mobile
|
||||
|
||||
## Attribute: `uw-asidenav`
|
||||
|
||||
## Example usage:
|
||||
```html
|
||||
<div uw-asidenav>
|
||||
<div .asidenav>
|
||||
<div .asidenav__box>
|
||||
<ul .asidenav__list.list--iconless>
|
||||
<li .asidenav__list-item>
|
||||
<a .asidenav__link-wrapper href='#'>
|
||||
<div .asidenav__link-shorthand>EIP
|
||||
<div .asidenav__link-label>Einführung in die Programmierung
|
||||
<div .asidenav__nested-list-wrapper>
|
||||
<ul .asidenav__nested-list.list--iconless>
|
||||
Übungsblätter
|
||||
...
|
||||
```
|
||||
@ -55,10 +55,6 @@
|
||||
.asidenav__box-title {
|
||||
font-size: 18px;
|
||||
padding-left: 10px;
|
||||
|
||||
&.js-show-hide__toggle::before {
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -94,18 +90,9 @@
|
||||
margin-top: 30px;
|
||||
background-color: transparent;
|
||||
transition: all .2s ease;
|
||||
padding: 30px 13px 10px;
|
||||
padding: 10px 13px;
|
||||
margin: 0;
|
||||
border-bottom: 1px solid var(--color-grey);
|
||||
|
||||
&.js-show-hide__toggle {
|
||||
|
||||
&::before {
|
||||
left: auto;
|
||||
right: 20px;
|
||||
color: var(--color-font);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* LOGO */
|
||||
@ -133,41 +120,32 @@
|
||||
flex-basis: var(--asidenav-width-xl, 250px);
|
||||
font-size: 16px;
|
||||
align-items: center;
|
||||
color: var(--color-primary);
|
||||
color: var(--color-dark);
|
||||
transform-origin: left;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-lightwhite);
|
||||
|
||||
.asidenav__logo-link-item {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.asidenav__logo-link-item {
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
height: calc(100% - 4px);
|
||||
padding: 0 6px 4px;
|
||||
border: 1px solid var(--color-primary);
|
||||
letter-spacing: 2px;
|
||||
background-color: var(--color-lightwhite);
|
||||
transition: background-color .3s ease;
|
||||
}
|
||||
|
||||
.asidenav__logo-lmu {
|
||||
font-family: var(--font-logo);
|
||||
font-size: 30px;
|
||||
width: 80px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.asidenav__logo-uni2work {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
min-width: 70px;
|
||||
margin-left: 12px;
|
||||
font-weight: normal;
|
||||
text-transform: uppercase;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 2px 4px;
|
||||
border: 1px solid currentColor;
|
||||
letter-spacing: 2px;
|
||||
background-color: white;
|
||||
transition: background-color .3s ease;
|
||||
}
|
||||
|
||||
@media (max-width: 1199px) {
|
||||
@ -186,6 +164,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* SEAL */
|
||||
|
||||
.asidenav__sigillum {
|
||||
position: absolute;
|
||||
bottom: -40px;
|
||||
right: 25px;
|
||||
opacity: 0.2;
|
||||
|
||||
> img {
|
||||
width: 350px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.asidenav__sigillum {
|
||||
right: auto;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
/* LIST-ITEM */
|
||||
|
||||
.asidenav__list-item {
|
||||
@ -218,7 +217,7 @@
|
||||
}
|
||||
|
||||
/* small list-item-padding for medium to large screens */
|
||||
@media (min-width: 1025px) {
|
||||
@media (min-width: 769px) {
|
||||
|
||||
.asidenav__list-item {
|
||||
padding-left: 10px;
|
||||
@ -361,9 +360,5 @@
|
||||
background-color: var(--color-lightwhite);
|
||||
}
|
||||
}
|
||||
|
||||
.js-show-hide__toggle::before {
|
||||
content: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
21
frontend/src/utils/asidenav/asidenav.spec.js
Normal file
21
frontend/src/utils/asidenav/asidenav.spec.js
Normal file
@ -0,0 +1,21 @@
|
||||
import { Asidenav } from './asidenav';
|
||||
|
||||
describe('Asidenav', () => {
|
||||
|
||||
let asidenav;
|
||||
|
||||
beforeEach(() => {
|
||||
const element = document.createElement('div');
|
||||
asidenav = new Asidenav(element);
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(asidenav).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should throw if called without an element', () => {
|
||||
expect(() => {
|
||||
new Asidenav();
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
95
frontend/src/utils/async-form/async-form.js
Normal file
95
frontend/src/utils/async-form/async-form.js
Normal file
@ -0,0 +1,95 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
import './async-form.scss';
|
||||
|
||||
const ASYNC_FORM_INITIALIZED_CLASS = 'check-all--initialized';
|
||||
const ASYNC_FORM_RESPONSE_CLASS = 'async-form__response';
|
||||
const ASYNC_FORM_LOADING_CLASS = 'async-form--loading';
|
||||
const ASYNC_FORM_MIN_DELAY = 600;
|
||||
|
||||
const MODAL_SELECTOR = '.modal';
|
||||
const MODAL_HEADER_KEY = 'Is-Modal';
|
||||
const MODAL_HEADER_VALUE = 'True';
|
||||
|
||||
@Utility({
|
||||
selector: 'form[uw-async-form]',
|
||||
})
|
||||
export class AsyncForm {
|
||||
|
||||
_lastRequestTimestamp = 0;
|
||||
_element;
|
||||
_app;
|
||||
|
||||
constructor(element, app) {
|
||||
if (!element) {
|
||||
throw new Error('Async Form Utility cannot be setup without an element!');
|
||||
}
|
||||
|
||||
this._element = element;
|
||||
this._app = app;
|
||||
|
||||
if (this._element.classList.contains(ASYNC_FORM_INITIALIZED_CLASS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._element.addEventListener('submit', this._submitHandler);
|
||||
|
||||
this._element.classList.add(ASYNC_FORM_INITIALIZED_CLASS);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
_processResponse(response) {
|
||||
const responseElement = this._makeResponseElement(response.content, response.status);
|
||||
const parentElement = this._element.parentElement;
|
||||
|
||||
// make sure there is a delay between click and response
|
||||
const delay = Math.max(0, ASYNC_FORM_MIN_DELAY + this._lastRequestTimestamp - Date.now());
|
||||
|
||||
setTimeout(() => {
|
||||
parentElement.insertBefore(responseElement, this._element);
|
||||
this._element.remove();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
_makeResponseElement(content, status) {
|
||||
const responseElement = document.createElement('div');
|
||||
status = status || 'info';
|
||||
responseElement.classList.add(ASYNC_FORM_RESPONSE_CLASS);
|
||||
responseElement.classList.add(ASYNC_FORM_RESPONSE_CLASS + '--' + status);
|
||||
responseElement.innerHTML = content;
|
||||
return responseElement;
|
||||
}
|
||||
|
||||
_submitHandler = (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
this._element.classList.add(ASYNC_FORM_LOADING_CLASS);
|
||||
this._lastRequestTimestamp = Date.now();
|
||||
|
||||
const url = this._element.getAttribute('action');
|
||||
const headers = { };
|
||||
const body = new FormData(this._element);
|
||||
|
||||
const isModal = this._element.closest(MODAL_SELECTOR);
|
||||
if (isModal) {
|
||||
headers[MODAL_HEADER_KEY] = MODAL_HEADER_VALUE;
|
||||
}
|
||||
|
||||
this._app.httpClient.post({
|
||||
url: url,
|
||||
headers: headers,
|
||||
body: body,
|
||||
}).then(
|
||||
(response) => response.json()
|
||||
).then(
|
||||
(response) => this._processResponse(response[0])
|
||||
).catch(() => {
|
||||
const failureMessage = this._app.i18n.get('asyncFormFailure');
|
||||
this._processResponse({ content: failureMessage });
|
||||
|
||||
this._element.classList.remove(ASYNC_FORM_LOADING_CLASS);
|
||||
});
|
||||
}
|
||||
}
|
||||
17
frontend/src/utils/async-form/async-form.md
Normal file
17
frontend/src/utils/async-form/async-form.md
Normal file
@ -0,0 +1,17 @@
|
||||
# Async Form Utility
|
||||
|
||||
Prevents form submissions from reloading the page but instead firing an AJAX request.
|
||||
|
||||
## Attribute: `uw-async-form`
|
||||
(works only on `<form>` elements)
|
||||
|
||||
## Example usage:
|
||||
```html
|
||||
<form uw-async-form method='POST' action='...'>
|
||||
...
|
||||
```
|
||||
|
||||
## Internationalization:
|
||||
This utility expects the following translations to be available:
|
||||
- `asyncFormFailure`\
|
||||
text that gets shown if an async form request fails (e.g. 'Oops. Something went wrong.').
|
||||
@ -1,4 +1,4 @@
|
||||
.async-form-response {
|
||||
.async-form__response {
|
||||
margin: 20px 0;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@ -7,15 +7,15 @@
|
||||
padding-top: 60px;
|
||||
}
|
||||
|
||||
.async-form-response::before,
|
||||
.async-form-response::after {
|
||||
.async-form__response::before,
|
||||
.async-form__response::after {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 50%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.async-form-response--success::before {
|
||||
.async-form__response--success::before {
|
||||
content: '';
|
||||
width: 17px;
|
||||
height: 28px;
|
||||
@ -24,7 +24,7 @@
|
||||
transform: translateX(-50%) rotate(45deg);
|
||||
}
|
||||
|
||||
.async-form-response--info::before {
|
||||
.async-form__response--info::before {
|
||||
content: '';
|
||||
width: 5px;
|
||||
height: 30px;
|
||||
@ -32,7 +32,7 @@
|
||||
background-color: #777;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.async-form-response--info::after {
|
||||
.async-form__response--info::after {
|
||||
content: '';
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
@ -40,14 +40,14 @@
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.async-form-response--warning::before {
|
||||
.async-form__response--warning::before {
|
||||
content: '';
|
||||
width: 5px;
|
||||
height: 30px;
|
||||
background-color: rgb(255, 187, 0);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.async-form-response--warning::after {
|
||||
.async-form__response--warning::after {
|
||||
content: '';
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
@ -56,14 +56,14 @@
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.async-form-response--error::before {
|
||||
.async-form__response--error::before {
|
||||
content: '';
|
||||
width: 5px;
|
||||
height: 40px;
|
||||
background-color: #940d0d;
|
||||
transform: translateX(-50%) rotate(-45deg);
|
||||
}
|
||||
.async-form-response--error::after {
|
||||
.async-form__response--error::after {
|
||||
content: '';
|
||||
width: 5px;
|
||||
height: 40px;
|
||||
@ -71,7 +71,7 @@
|
||||
transform: translateX(-50%) rotate(45deg);
|
||||
}
|
||||
|
||||
.async-form-loading {
|
||||
.async-form--loading {
|
||||
opacity: 0.1;
|
||||
transition: opacity 800ms ease-out;
|
||||
pointer-events: none;
|
||||
21
frontend/src/utils/async-form/async-form.spec.js
Normal file
21
frontend/src/utils/async-form/async-form.spec.js
Normal file
@ -0,0 +1,21 @@
|
||||
import { AsyncForm } from './async-form';
|
||||
|
||||
describe('AsyncForm', () => {
|
||||
|
||||
let asyncForm;
|
||||
|
||||
beforeEach(() => {
|
||||
const element = document.createElement('div');
|
||||
asyncForm = new AsyncForm(element);
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(asyncForm).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should throw if called without an element', () => {
|
||||
expect(() => {
|
||||
new AsyncForm();
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
376
frontend/src/utils/async-table/async-table.js
Normal file
376
frontend/src/utils/async-table/async-table.js
Normal file
@ -0,0 +1,376 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
import { HttpClient } from '../../services/http-client/http-client';
|
||||
import * as debounce from 'lodash.debounce';
|
||||
import './async-table-filter.scss';
|
||||
import './async-table.scss';
|
||||
|
||||
const INPUT_DEBOUNCE = 600;
|
||||
const HEADER_HEIGHT = 80;
|
||||
|
||||
const ASYNC_TABLE_LOCAL_STORAGE_KEY = 'ASYNC_TABLE';
|
||||
const ASYNC_TABLE_SCROLLTABLE_SELECTOR = '.scrolltable';
|
||||
const ASYNC_TABLE_INITIALIZED_CLASS = 'async-table--initialized';
|
||||
const ASYNC_TABLE_LOADING_CLASS = 'async-table--loading';
|
||||
|
||||
const ASYNC_TABLE_FILTER_FORM_SELECTOR = '.table-filter-form';
|
||||
|
||||
@Utility({
|
||||
selector: '[uw-async-table]',
|
||||
})
|
||||
export class AsyncTable {
|
||||
|
||||
_element;
|
||||
_app;
|
||||
|
||||
_asyncTableHeader;
|
||||
_asyncTableId;
|
||||
|
||||
_ths = [];
|
||||
_pageLinks = [];
|
||||
_pagesizeForm;
|
||||
_scrollTable;
|
||||
_cssIdPrefix = '';
|
||||
|
||||
_tableFilterInputs = {
|
||||
search: [],
|
||||
input: [],
|
||||
change: [],
|
||||
select: [],
|
||||
};
|
||||
_ignoreRequest = false;
|
||||
|
||||
constructor(element, app) {
|
||||
if (!element) {
|
||||
throw new Error('Async Table utility cannot be setup without an element!');
|
||||
}
|
||||
|
||||
if (!app) {
|
||||
throw new Error('Async Table utility cannot be setup without an app!');
|
||||
}
|
||||
|
||||
this._element = element;
|
||||
this._app = app;
|
||||
|
||||
if (this._element.classList.contains(ASYNC_TABLE_INITIALIZED_CLASS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// param asyncTableDbHeader
|
||||
if (this._element.dataset.asyncTableDbHeader !== undefined) {
|
||||
this._asyncTableHeader = this._element.dataset.asyncTableDbHeader;
|
||||
}
|
||||
|
||||
const table = this._element.querySelector('table');
|
||||
if (!table) {
|
||||
throw new Error('Async Table utility needs a <table> in its element!');
|
||||
}
|
||||
|
||||
const rawTableId = table.id;
|
||||
this._cssIdPrefix = findCssIdPrefix(rawTableId);
|
||||
this._asyncTableId = rawTableId.replace(this._cssIdPrefix, '');
|
||||
|
||||
// find scrolltable wrapper
|
||||
this._scrollTable = this._element.querySelector(ASYNC_TABLE_SCROLLTABLE_SELECTOR);
|
||||
if (!this._scrollTable) {
|
||||
throw new Error('Async Table cannot be set up without a scrolltable element!');
|
||||
}
|
||||
|
||||
this._setupSortableHeaders();
|
||||
this._setupPagination();
|
||||
this._setupPageSizeSelect();
|
||||
this._setupTableFilter();
|
||||
|
||||
this._processLocalStorage();
|
||||
|
||||
// clear currentTableUrl from previous requests
|
||||
setLocalStorageParameter('currentTableUrl', null);
|
||||
|
||||
// mark initialized
|
||||
this._element.classList.add(ASYNC_TABLE_INITIALIZED_CLASS);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
console.log('TBD: Destroy AsyncTable');
|
||||
}
|
||||
|
||||
_setupSortableHeaders() {
|
||||
this._ths = Array.from(this._scrollTable.querySelectorAll('th.sortable'))
|
||||
.map((th) => ({ element: th }));
|
||||
|
||||
this._ths.forEach((th) => {
|
||||
th.clickHandler = (event) => {
|
||||
setLocalStorageParameter('horizPos', (this._scrollTable || {}).scrollLeft);
|
||||
this._linkClickHandler(event);
|
||||
};
|
||||
th.element.addEventListener('click', th.clickHandler);
|
||||
});
|
||||
}
|
||||
|
||||
_setupPagination() {
|
||||
const pagination = this._element.querySelector('#' + this._cssIdPrefix + this._asyncTableId + '-pagination');
|
||||
if (pagination) {
|
||||
this._pageLinks = Array.from(pagination.querySelectorAll('.page-link'))
|
||||
.map((link) => ({ element: link }));
|
||||
|
||||
this._pageLinks.forEach((link) => {
|
||||
link.clickHandler = (event) => {
|
||||
const tableBoundingRect = this._scrollTable.getBoundingClientRect();
|
||||
if (tableBoundingRect.top < HEADER_HEIGHT) {
|
||||
const scrollTo = {
|
||||
top: (this._scrollTable.offsetTop || 0) - HEADER_HEIGHT,
|
||||
left: this._scrollTable.offsetLeft || 0,
|
||||
behavior: 'smooth',
|
||||
};
|
||||
setLocalStorageParameter('scrollTo', scrollTo);
|
||||
}
|
||||
this._linkClickHandler(event);
|
||||
};
|
||||
link.element.addEventListener('click', link.clickHandler);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_setupPageSizeSelect() {
|
||||
// pagesize form
|
||||
this._pagesizeForm = this._element.querySelector('#' + this._cssIdPrefix + this._asyncTableId + '-pagesize-form');
|
||||
|
||||
if (this._pagesizeForm) {
|
||||
const pagesizeSelect = this._pagesizeForm.querySelector('[name=' + this._asyncTableId + '-pagesize]');
|
||||
pagesizeSelect.addEventListener('change', this._changePagesizeHandler);
|
||||
}
|
||||
}
|
||||
|
||||
_setupTableFilter() {
|
||||
const tableFilterForm = this._element.querySelector(ASYNC_TABLE_FILTER_FORM_SELECTOR);
|
||||
if (tableFilterForm) {
|
||||
this._gatherTableFilterInputs(tableFilterForm);
|
||||
this._addTableFilterEventListeners(tableFilterForm);
|
||||
}
|
||||
}
|
||||
|
||||
_gatherTableFilterInputs(tableFilterForm) {
|
||||
Array.from(tableFilterForm.querySelectorAll('input[type="search"]')).forEach((input) => {
|
||||
this._tableFilterInputs.search.push(input);
|
||||
});
|
||||
|
||||
Array.from(tableFilterForm.querySelectorAll('input[type="text"]')).forEach((input) => {
|
||||
this._tableFilterInputs.input.push(input);
|
||||
});
|
||||
|
||||
Array.from(tableFilterForm.querySelectorAll('input:not([type="text"]):not([type="search"])')).forEach((input) => {
|
||||
this._tableFilterInputs.change.push(input);
|
||||
});
|
||||
|
||||
Array.from(tableFilterForm.querySelectorAll('select')).forEach((input) => {
|
||||
this._tableFilterInputs.select.push(input);
|
||||
});
|
||||
}
|
||||
|
||||
_addTableFilterEventListeners(tableFilterForm) {
|
||||
this._tableFilterInputs.search.forEach((input) => {
|
||||
const debouncedInput = debounce(() => {
|
||||
if (input.value.length === 0 || input.value.length > 2) {
|
||||
this._updateFromTableFilter(tableFilterForm);
|
||||
}
|
||||
}, INPUT_DEBOUNCE);
|
||||
input.addEventListener('input', debouncedInput);
|
||||
input.addEventListener('input', () => {
|
||||
// set flag to ignore any currently pending requests (not debounced)
|
||||
this._ignoreRequest = true;
|
||||
});
|
||||
});
|
||||
|
||||
this._tableFilterInputs.input.forEach((input) => {
|
||||
const debouncedInput = debounce(() => {
|
||||
if (input.value.length === 0 || input.value.length > 2) {
|
||||
this._updateFromTableFilter(tableFilterForm);
|
||||
}
|
||||
}, INPUT_DEBOUNCE);
|
||||
input.addEventListener('input', debouncedInput);
|
||||
input.addEventListener('input', () => {
|
||||
// set flag to ignore any currently pending requests (not debounced)
|
||||
this._ignoreRequest = true;
|
||||
});
|
||||
});
|
||||
|
||||
this._tableFilterInputs.change.forEach((input) => {
|
||||
input.addEventListener('change', () => {
|
||||
this._updateFromTableFilter(tableFilterForm);
|
||||
});
|
||||
});
|
||||
|
||||
this._tableFilterInputs.select.forEach((input) => {
|
||||
input.addEventListener('change', () => {
|
||||
this._updateFromTableFilter(tableFilterForm);
|
||||
});
|
||||
});
|
||||
|
||||
tableFilterForm.addEventListener('submit', (event) =>{
|
||||
event.preventDefault();
|
||||
this._updateFromTableFilter(tableFilterForm);
|
||||
});
|
||||
}
|
||||
|
||||
_updateFromTableFilter(tableFilterForm) {
|
||||
const url = this._serializeTableFilterToURL(tableFilterForm);
|
||||
let callback = null;
|
||||
|
||||
const focusedInput = tableFilterForm.querySelector(':focus, :active');
|
||||
// focus previously focused input
|
||||
if (focusedInput && focusedInput.selectionStart !== null) {
|
||||
const selectionStart = focusedInput.selectionStart;
|
||||
// remove the following part of the id to get rid of the random
|
||||
// (yet somewhat structured) prefix we got from nudging.
|
||||
const prefix = findCssIdPrefix(focusedInput.id);
|
||||
const focusId = focusedInput.id.replace(prefix, '');
|
||||
callback = function(wrapper) {
|
||||
const idPrefix = getLocalStorageParameter('cssIdPrefix');
|
||||
const toBeFocused = wrapper.querySelector('#' + idPrefix + focusId);
|
||||
if (toBeFocused) {
|
||||
toBeFocused.focus();
|
||||
toBeFocused.selectionStart = selectionStart;
|
||||
}
|
||||
};
|
||||
}
|
||||
this._ignoreRequest = false;
|
||||
this._updateTableFrom(url, callback);
|
||||
}
|
||||
|
||||
_serializeTableFilterToURL(tableFilterForm) {
|
||||
const url = new URL(getLocalStorageParameter('currentTableUrl') || window.location.href);
|
||||
const formData = new FormData(tableFilterForm);
|
||||
|
||||
for (var k of url.searchParams.keys()) {
|
||||
url.searchParams.delete(k);
|
||||
}
|
||||
|
||||
for (var kv of formData.entries()) {
|
||||
url.searchParams.append(kv[0], kv[1]);
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
_processLocalStorage() {
|
||||
const scrollTo = getLocalStorageParameter('scrollTo');
|
||||
if (scrollTo && this._scrollTable) {
|
||||
window.scrollTo(scrollTo);
|
||||
}
|
||||
setLocalStorageParameter('scrollTo', null);
|
||||
|
||||
const horizPos = getLocalStorageParameter('horizPos');
|
||||
if (horizPos && this._scrollTable) {
|
||||
this._scrollTable.scrollLeft = horizPos;
|
||||
}
|
||||
setLocalStorageParameter('horizPos', null);
|
||||
}
|
||||
|
||||
_removeListeners() {
|
||||
this._ths.forEach(function(th) {
|
||||
th.element.removeEventListener('click', th.clickHandler);
|
||||
});
|
||||
|
||||
this._pageLinks.forEach(function(link) {
|
||||
link.element.removeEventListener('click', link.clickHandler);
|
||||
});
|
||||
|
||||
if (this._pagesizeForm) {
|
||||
const pagesizeSelect = this._pagesizeForm.querySelector('[name=' + this._asyncTableId + '-pagesize]');
|
||||
pagesizeSelect.removeEventListener('change', this._changePagesizeHandler);
|
||||
}
|
||||
}
|
||||
|
||||
_linkClickHandler = (event) => {
|
||||
event.preventDefault();
|
||||
let url = this._getClickDestination(event.target);
|
||||
if (!url.match(/^http/)) {
|
||||
url = window.location.origin + window.location.pathname + url;
|
||||
}
|
||||
this._updateTableFrom(url);
|
||||
}
|
||||
|
||||
_getClickDestination(el) {
|
||||
if (!el.matches('a') && !el.querySelector('a')) {
|
||||
return '';
|
||||
}
|
||||
return el.getAttribute('href') || el.querySelector('a').getAttribute('href');
|
||||
}
|
||||
|
||||
_changePagesizeHandler = () => {
|
||||
const url = new URL(getLocalStorageParameter('currentTableUrl') || window.location.href);
|
||||
const formData = new FormData(this._pagesizeForm);
|
||||
|
||||
for (var k of url.searchParams.keys()) {
|
||||
url.searchParams.delete(k);
|
||||
}
|
||||
|
||||
for (var kv of formData.entries()) {
|
||||
url.searchParams.append(kv[0], kv[1]);
|
||||
}
|
||||
|
||||
this._updateTableFrom(url.href);
|
||||
}
|
||||
|
||||
// fetches new sorted element from url with params and replaces contents of current element
|
||||
_updateTableFrom(url, callback) {
|
||||
this._element.classList.add(ASYNC_TABLE_LOADING_CLASS);
|
||||
|
||||
const headers = {
|
||||
'Accept': HttpClient.ACCEPT.TEXT_HTML,
|
||||
[this._asyncTableHeader]: this._asyncTableId,
|
||||
};
|
||||
|
||||
this._app.httpClient.get({
|
||||
url: url,
|
||||
headers: headers,
|
||||
}).then(
|
||||
(response) => this._app.htmlHelpers.parseResponse(response),
|
||||
).then((response) => {
|
||||
// check if request should be ignored
|
||||
if (this._ignoreRequest) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setLocalStorageParameter('currentTableUrl', url.href);
|
||||
// reset table
|
||||
this._removeListeners();
|
||||
this._element.classList.remove(ASYNC_TABLE_INITIALIZED_CLASS);
|
||||
// update table with new
|
||||
this._element.innerHTML = response.element.innerHTML;
|
||||
|
||||
this._app.utilRegistry.setupAll(this._element);
|
||||
|
||||
if (callback && typeof callback === 'function') {
|
||||
setLocalStorageParameter('cssIdPrefix', response.idPrefix);
|
||||
callback(this._element);
|
||||
setLocalStorageParameter('cssIdPrefix', '');
|
||||
}
|
||||
}).catch((err) => console.error(err)
|
||||
).finally(() => this._element.classList.remove(ASYNC_TABLE_LOADING_CLASS));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// returns any random nudged prefix found in the given id
|
||||
function findCssIdPrefix(id) {
|
||||
const matcher = /r\d*?__/;
|
||||
const maybePrefix = id.match(matcher);
|
||||
if (maybePrefix && maybePrefix[0]) {
|
||||
return maybePrefix[0];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function setLocalStorageParameter(key, value) {
|
||||
const currentLSState = JSON.parse(window.localStorage.getItem(ASYNC_TABLE_LOCAL_STORAGE_KEY)) || {};
|
||||
if (value !== null) {
|
||||
currentLSState[key] = value;
|
||||
} else {
|
||||
delete currentLSState[key];
|
||||
}
|
||||
window.localStorage.setItem(ASYNC_TABLE_LOCAL_STORAGE_KEY, JSON.stringify(currentLSState));
|
||||
}
|
||||
function getLocalStorageParameter(key) {
|
||||
const currentLSState = JSON.parse(window.localStorage.getItem(ASYNC_TABLE_LOCAL_STORAGE_KEY)) || {};
|
||||
return currentLSState[key];
|
||||
}
|
||||
7
frontend/src/utils/async-table/async-table.md
Normal file
7
frontend/src/utils/async-table/async-table.md
Normal file
@ -0,0 +1,7 @@
|
||||
# Async Table Utility
|
||||
Makes table filters, sorting and pagination behave asynchronously via AJAX calls.
|
||||
|
||||
## Attribute: `uw-async-table`
|
||||
|
||||
## Example usage:
|
||||
(any regular table)
|
||||
52
frontend/src/utils/async-table/async-table.spec.js
Normal file
52
frontend/src/utils/async-table/async-table.spec.js
Normal file
@ -0,0 +1,52 @@
|
||||
import { AsyncTable } from './async-table';
|
||||
|
||||
const AppTestMock = {
|
||||
httpClient: {
|
||||
get: () => {},
|
||||
},
|
||||
htmlHelpers: {
|
||||
parseResponse: () => {},
|
||||
},
|
||||
utilRegistry: {
|
||||
setupAll: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
describe('AsyncTable', () => {
|
||||
|
||||
let asyncTable;
|
||||
|
||||
beforeEach(() => {
|
||||
const element = document.createElement('div');
|
||||
const scrollTable = document.createElement('div');
|
||||
const table = document.createElement('table');
|
||||
scrollTable.classList.add('scrolltable');
|
||||
scrollTable.appendChild(table);
|
||||
element.appendChild(scrollTable);
|
||||
asyncTable = new AsyncTable(element, AppTestMock);
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(asyncTable).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should throw if element does not contain a .scrolltable', () => {
|
||||
const element = document.createElement('div');
|
||||
expect(() => {
|
||||
new AsyncTable(element, AppTestMock);
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('should throw if element does not contain a table', () => {
|
||||
const element = document.createElement('div');
|
||||
expect(() => {
|
||||
new AsyncTable(element, AppTestMock);
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('should throw if called without an element', () => {
|
||||
expect(() => {
|
||||
new AsyncTable();
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
127
frontend/src/utils/check-all/check-all.js
Normal file
127
frontend/src/utils/check-all/check-all.js
Normal file
@ -0,0 +1,127 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
|
||||
const CHECKBOX_SELECTOR = '[type="checkbox"]';
|
||||
|
||||
const CHECK_ALL_INITIALIZED_CLASS = 'check-all--initialized';
|
||||
|
||||
@Utility({
|
||||
selector: 'table',
|
||||
})
|
||||
export class CheckAll {
|
||||
|
||||
_element;
|
||||
_app;
|
||||
|
||||
_columns = [];
|
||||
_checkboxColumn = [];
|
||||
_checkAllCheckbox = null;
|
||||
|
||||
constructor(element, app) {
|
||||
if (!element) {
|
||||
throw new Error('Check All utility cannot be setup without an element!');
|
||||
}
|
||||
|
||||
this._element = element;
|
||||
this._app = app;
|
||||
|
||||
if (this._element.classList.contains(CHECK_ALL_INITIALIZED_CLASS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._gatherColumns();
|
||||
this._setupCheckAllCheckbox();
|
||||
|
||||
// mark initialized
|
||||
this._element.classList.add(CHECK_ALL_INITIALIZED_CLASS);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._checkAllCheckbox.destroy();
|
||||
}
|
||||
|
||||
_getCheckboxId() {
|
||||
return 'check-all-checkbox-' + Math.floor(Math.random() * 100000);
|
||||
}
|
||||
|
||||
_gatherColumns() {
|
||||
const rows = Array.from(this._element.querySelectorAll('tr'));
|
||||
const cols = [];
|
||||
rows.forEach((tr) => {
|
||||
const cells = Array.from(tr.querySelectorAll('td'));
|
||||
cells.forEach((cell, cellIndex) => {
|
||||
if (!cols[cellIndex]) {
|
||||
cols[cellIndex] = [];
|
||||
}
|
||||
cols[cellIndex].push(cell);
|
||||
});
|
||||
});
|
||||
this._columns = cols;
|
||||
}
|
||||
|
||||
_findCheckboxColumn(columns) {
|
||||
let checkboxColumnId = null;
|
||||
columns.forEach((col, i) => {
|
||||
if (this._isCheckboxColumn(col)) {
|
||||
checkboxColumnId = i;
|
||||
}
|
||||
});
|
||||
return checkboxColumnId;
|
||||
}
|
||||
|
||||
_isCheckboxColumn(col) {
|
||||
let onlyCheckboxes = true;
|
||||
col.forEach((cell) => {
|
||||
if (onlyCheckboxes && !cell.querySelector(CHECKBOX_SELECTOR)) {
|
||||
onlyCheckboxes = false;
|
||||
}
|
||||
});
|
||||
return onlyCheckboxes;
|
||||
}
|
||||
|
||||
_setupCheckAllCheckbox() {
|
||||
const checkboxColumnId = this._findCheckboxColumn(this._columns);
|
||||
if (checkboxColumnId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._checkboxColumn = this._columns[checkboxColumnId];
|
||||
const firstRow = this._element.querySelector('tr');
|
||||
const th = Array.from(firstRow.querySelectorAll('th, td'))[checkboxColumnId];
|
||||
this._checkAllCheckbox = document.createElement('input');
|
||||
this._checkAllCheckbox.setAttribute('type', 'checkbox');
|
||||
this._checkAllCheckbox.setAttribute('id', this._getCheckboxId());
|
||||
th.insertBefore(this._checkAllCheckbox, th.firstChild);
|
||||
|
||||
// set up new checkbox
|
||||
this._app.utilRegistry.setupAll(th);
|
||||
|
||||
this._checkAllCheckbox.addEventListener('input', () => this._onCheckAllCheckboxInput());
|
||||
this._setupCheckboxListeners();
|
||||
}
|
||||
|
||||
_onCheckAllCheckboxInput() {
|
||||
this._toggleAll(this._checkAllCheckbox.checked);
|
||||
}
|
||||
|
||||
_setupCheckboxListeners() {
|
||||
this._checkboxColumn.map((cell) => {
|
||||
return cell.querySelector(CHECKBOX_SELECTOR);
|
||||
})
|
||||
.forEach((checkbox) => {
|
||||
checkbox.addEventListener('input', () => this._updateCheckAllCheckboxState());
|
||||
});
|
||||
}
|
||||
|
||||
_updateCheckAllCheckboxState() {
|
||||
const allChecked = this._checkboxColumn.every((cell) => {
|
||||
return cell.querySelector(CHECKBOX_SELECTOR).checked;
|
||||
});
|
||||
this._checkAllCheckbox.checked = allChecked;
|
||||
}
|
||||
|
||||
_toggleAll(checked) {
|
||||
this._checkboxColumn.forEach((cell) => {
|
||||
cell.querySelector(CHECKBOX_SELECTOR).checked = checked;
|
||||
});
|
||||
}
|
||||
}
|
||||
9
frontend/src/utils/check-all/check-all.md
Normal file
9
frontend/src/utils/check-all/check-all.md
Normal file
@ -0,0 +1,9 @@
|
||||
# Check All Checkbox Utility
|
||||
Adds a Check All Checkbox above columns with only checkboxes
|
||||
|
||||
## Attribute: (none)
|
||||
(will be set up automatically on tables)
|
||||
|
||||
## Example usage:
|
||||
(table with exactly one column thats only checkboxes)
|
||||
|
||||
27
frontend/src/utils/check-all/check-all.spec.js
Normal file
27
frontend/src/utils/check-all/check-all.spec.js
Normal file
@ -0,0 +1,27 @@
|
||||
import { CheckAll } from './check-all';
|
||||
|
||||
const MOCK_APP = {
|
||||
utilRegistry: {
|
||||
setupAll: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
describe('CheckAll', () => {
|
||||
|
||||
let checkAll;
|
||||
|
||||
beforeEach(() => {
|
||||
const element = document.createElement('div');
|
||||
checkAll = new CheckAll(element, MOCK_APP);
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(checkAll).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should throw if called without an element', () => {
|
||||
expect(() => {
|
||||
new CheckAll();
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
29
frontend/src/utils/form/auto-submit-button.js
Normal file
29
frontend/src/utils/form/auto-submit-button.js
Normal file
@ -0,0 +1,29 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
|
||||
export const AUTO_SUBMIT_BUTTON_UTIL_SELECTOR = '[uw-auto-submit-button]';
|
||||
|
||||
const AUTO_SUBMIT_BUTTON_INITIALIZED_CLASS = 'auto-submit-button--initialized';
|
||||
const AUTO_SUBMIT_BUTTON_HIDDEN_CLASS = 'hidden';
|
||||
|
||||
@Utility({
|
||||
selector: AUTO_SUBMIT_BUTTON_UTIL_SELECTOR,
|
||||
})
|
||||
export class AutoSubmitButton {
|
||||
|
||||
constructor(element) {
|
||||
if (!element) {
|
||||
throw new Error('Auto Submit Button utility needs to be passed an element!');
|
||||
}
|
||||
|
||||
if (element.classList.contains(AUTO_SUBMIT_BUTTON_INITIALIZED_CLASS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// hide and mark initialized
|
||||
element.classList.add(AUTO_SUBMIT_BUTTON_HIDDEN_CLASS, AUTO_SUBMIT_BUTTON_INITIALIZED_CLASS);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
11
frontend/src/utils/form/auto-submit-button.md
Normal file
11
frontend/src/utils/form/auto-submit-button.md
Normal file
@ -0,0 +1,11 @@
|
||||
# Auto Submit Button Utility
|
||||
Hides submit buttons in forms that are submitted programmatically.
|
||||
We hide the button using JavaScript so no-js users will still be able to submit the form.
|
||||
|
||||
## Attribute: `uw-auto-submit-button`
|
||||
|
||||
## Example usage:
|
||||
```html
|
||||
<button type='submit' uw-auto-submit-button>Submit
|
||||
```
|
||||
|
||||
47
frontend/src/utils/form/auto-submit-input.js
Normal file
47
frontend/src/utils/form/auto-submit-input.js
Normal file
@ -0,0 +1,47 @@
|
||||
import * as debounce from 'lodash.debounce';
|
||||
import { Utility } from '../../core/utility';
|
||||
|
||||
export const AUTO_SUBMIT_INPUT_UTIL_SELECTOR = '[uw-auto-submit-input]';
|
||||
|
||||
const AUTO_SUBMIT_INPUT_INITIALIZED_CLASS = 'auto-submit-input--initialized';
|
||||
|
||||
@Utility({
|
||||
selector: AUTO_SUBMIT_INPUT_UTIL_SELECTOR,
|
||||
})
|
||||
export class AutoSubmitInput {
|
||||
|
||||
_element;
|
||||
|
||||
_form;
|
||||
_debouncedHandler;
|
||||
|
||||
constructor(element) {
|
||||
if (!element) {
|
||||
throw new Error('Auto Submit Input utility needs to be passed an element!');
|
||||
}
|
||||
|
||||
this._element = element;
|
||||
|
||||
if (this._element.classList.contains(AUTO_SUBMIT_INPUT_INITIALIZED_CLASS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._form = this._element.form;
|
||||
if (!this._form) {
|
||||
throw new Error('Could not determine associated form for auto submit input');
|
||||
}
|
||||
|
||||
this._debouncedHandler = debounce(this.autoSubmit, 500);
|
||||
|
||||
this._element.addEventListener('input', this._debouncedHandler);
|
||||
this._element.classList.add(AUTO_SUBMIT_INPUT_INITIALIZED_CLASS);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._element.removeEventListener('input', this._debouncedHandler);
|
||||
}
|
||||
|
||||
autoSubmit = () => {
|
||||
this._form.submit();
|
||||
}
|
||||
}
|
||||
9
frontend/src/utils/form/auto-submit-input.md
Normal file
9
frontend/src/utils/form/auto-submit-input.md
Normal file
@ -0,0 +1,9 @@
|
||||
# Auto Submit Input Utility
|
||||
Programmatically submits forms when a certain input changes value
|
||||
|
||||
## Attribute: `uw-auto-submit-input`
|
||||
|
||||
## Example usage:
|
||||
```html
|
||||
<input type='text' uw-auto-submit-input />
|
||||
```
|
||||
62
frontend/src/utils/form/datepicker.js
Normal file
62
frontend/src/utils/form/datepicker.js
Normal file
@ -0,0 +1,62 @@
|
||||
import flatpickr from 'flatpickr';
|
||||
import { Utility } from '../../core/utility';
|
||||
|
||||
const DATEPICKER_UTIL_SELECTOR = 'input[type="date"], input[type="time"], input[type="datetime-local"]';
|
||||
|
||||
const DATEPICKER_INITIALIZED_CLASS = 'datepicker--initialized';
|
||||
|
||||
const DATEPICKER_CONFIG = {
|
||||
'datetime-local': {
|
||||
enableTime: true,
|
||||
altInput: true,
|
||||
altFormat: 'j. F Y, H:i', // maybe interpolate these formats for locale
|
||||
dateFormat: 'Y-m-dTH:i',
|
||||
time_24hr: true,
|
||||
},
|
||||
'date': {
|
||||
altFormat: 'j. F Y',
|
||||
dateFormat: 'Y-m-d',
|
||||
altInput: true,
|
||||
},
|
||||
'time': {
|
||||
enableTime: true,
|
||||
noCalendar: true,
|
||||
altFormat: 'H:i',
|
||||
dateFormat: 'H:i',
|
||||
altInput: true,
|
||||
time_24hr: true,
|
||||
},
|
||||
};
|
||||
|
||||
@Utility({
|
||||
selector: DATEPICKER_UTIL_SELECTOR,
|
||||
})
|
||||
export class Datepicker {
|
||||
|
||||
flatpickrInstance;
|
||||
|
||||
constructor(element) {
|
||||
if (!element) {
|
||||
throw new Error('Datepicker utility needs to be passed an element!');
|
||||
}
|
||||
|
||||
if (element.classList.contains(DATEPICKER_INITIALIZED_CLASS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const flatpickrConfig = DATEPICKER_CONFIG[element.getAttribute('type')];
|
||||
|
||||
if (!flatpickrConfig) {
|
||||
throw new Error('Datepicker utility called on unsupported element!');
|
||||
}
|
||||
|
||||
this.flatpickrInstance = flatpickr(element, flatpickrConfig);
|
||||
|
||||
// mark initialized
|
||||
element.classList.add(DATEPICKER_INITIALIZED_CLASS);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.flatpickrInstance.destroy();
|
||||
}
|
||||
}
|
||||
8
frontend/src/utils/form/datepicker.md
Normal file
8
frontend/src/utils/form/datepicker.md
Normal file
@ -0,0 +1,8 @@
|
||||
# Datepicker Utility
|
||||
Provides UI for entering dates and times
|
||||
|
||||
## Attribute: (none)
|
||||
(automatically setup on all relevant input tags)
|
||||
|
||||
## Example usage:
|
||||
(any form that uses inputs of type date, time, or datetime-local)
|
||||
46
frontend/src/utils/form/form-error-remover.js
Normal file
46
frontend/src/utils/form/form-error-remover.js
Normal file
@ -0,0 +1,46 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
|
||||
const FORM_ERROR_REMOVER_INITIALIZED_CLASS = 'form-error-remover--initialized';
|
||||
const FORM_ERROR_REMOVER_INPUTS_SELECTOR = 'input:not([type="hidden"]), textarea, select';
|
||||
|
||||
const FORM_GROUP_SELECTOR = '.form-group';
|
||||
const FORM_GROUP_WITH_ERRORS_CLASS = 'form-group--has-error';
|
||||
|
||||
@Utility({
|
||||
selector: 'form',
|
||||
})
|
||||
export class FormErrorRemover {
|
||||
|
||||
constructor(element) {
|
||||
if (!element) {
|
||||
throw new Error('Form Error Remover utility needs to be passed an element!');
|
||||
}
|
||||
|
||||
if (element.classList.contains(FORM_ERROR_REMOVER_INITIALIZED_CLASS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// find form groups
|
||||
const formGroups = Array.from(element.querySelectorAll(FORM_GROUP_SELECTOR));
|
||||
|
||||
formGroups.forEach((formGroup) => {
|
||||
if (!formGroup.classList.contains(FORM_GROUP_WITH_ERRORS_CLASS)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inputElements = Array.from(formGroup.querySelectorAll(FORM_ERROR_REMOVER_INPUTS_SELECTOR));
|
||||
if (!inputElements) {
|
||||
return false;
|
||||
}
|
||||
|
||||
inputElements.forEach((inputElement) => {
|
||||
inputElement.addEventListener('input', () => {
|
||||
formGroup.classList.remove(FORM_GROUP_WITH_ERRORS_CLASS);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// mark initialized
|
||||
element.classList.add(FORM_ERROR_REMOVER_INITIALIZED_CLASS);
|
||||
}
|
||||
}
|
||||
8
frontend/src/utils/form/form-error-remover.md
Normal file
8
frontend/src/utils/form/form-error-remover.md
Normal file
@ -0,0 +1,8 @@
|
||||
# Form Error Remover Utility
|
||||
Removes errors from inputs when they are focused
|
||||
|
||||
## Attribute: (none)
|
||||
(automatically setup on all form tags)
|
||||
|
||||
## Example usage:
|
||||
(any regular form that can show input errors)
|
||||
17
frontend/src/utils/form/form.js
Normal file
17
frontend/src/utils/form/form.js
Normal file
@ -0,0 +1,17 @@
|
||||
import './form.scss';
|
||||
import { AutoSubmitButton } from './auto-submit-button';
|
||||
import { AutoSubmitInput } from './auto-submit-input';
|
||||
import { Datepicker } from './datepicker';
|
||||
import { FormErrorRemover } from './form-error-remover';
|
||||
import { InteractiveFieldset } from './interactive-fieldset';
|
||||
import { NavigateAwayPrompt } from './navigate-away-prompt';
|
||||
|
||||
export const FormUtils = [
|
||||
AutoSubmitButton,
|
||||
AutoSubmitInput,
|
||||
Datepicker,
|
||||
FormErrorRemover,
|
||||
InteractiveFieldset,
|
||||
NavigateAwayPrompt,
|
||||
// ReactiveSubmitButton // not used currently
|
||||
];
|
||||
@ -1,23 +1,20 @@
|
||||
fieldset {
|
||||
border: 0;
|
||||
margin: 20px 0 30px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
legend {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.form-group__input > fieldset {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
.form-group__input {
|
||||
grid-column: 2;
|
||||
}
|
||||
}
|
||||
|
||||
[data-autosubmit][type="submit"] {
|
||||
[uw-auto-submit-button][type='submit'] {
|
||||
animation: fade-in 500ms ease-in-out backwards;
|
||||
animation-delay: 500ms;
|
||||
}
|
||||
97
frontend/src/utils/form/interactive-fieldset.js
Normal file
97
frontend/src/utils/form/interactive-fieldset.js
Normal file
@ -0,0 +1,97 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
|
||||
const INTERACTIVE_FIELDSET_UTIL_TARGET_SELECTOR = '.interactive-fieldset__target';
|
||||
|
||||
const INTERACTIVE_FIELDSET_INITIALIZED_CLASS = 'interactive-fieldset--initialized';
|
||||
const INTERACTIVE_FIELDSET_CHILD_SELECTOR = 'input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled])';
|
||||
|
||||
@Utility({
|
||||
selector: '[uw-interactive-fieldset]',
|
||||
})
|
||||
export class InteractiveFieldset {
|
||||
|
||||
_element;
|
||||
|
||||
conditionalInput;
|
||||
conditionalValue;
|
||||
target;
|
||||
childInputs;
|
||||
|
||||
constructor(element) {
|
||||
if (!element) {
|
||||
throw new Error('Interactive Fieldset utility cannot be setup without an element!');
|
||||
}
|
||||
|
||||
this._element = element;
|
||||
|
||||
if (this._element.classList.contains(INTERACTIVE_FIELDSET_INITIALIZED_CLASS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// param conditionalInput
|
||||
if (!this._element.dataset.conditionalInput) {
|
||||
throw new Error('Interactive Fieldset needs a selector for a conditional input!');
|
||||
}
|
||||
this.conditionalInput = document.querySelector('#' + this._element.dataset.conditionalInput);
|
||||
if (!this.conditionalInput) {
|
||||
// abort if form has no required inputs
|
||||
throw new Error('Couldn\'t find the conditional input. Aborting setup for interactive fieldset.');
|
||||
}
|
||||
|
||||
// param conditionalValue
|
||||
if (!this._element.dataset.conditionalValue && !this._isCheckbox()) {
|
||||
throw new Error('Interactive Fieldset needs a conditional value!');
|
||||
}
|
||||
this.conditionalValue = this._element.dataset.conditionalValue;
|
||||
|
||||
this.target = this._element.closest(INTERACTIVE_FIELDSET_UTIL_TARGET_SELECTOR);
|
||||
if (!this.target || this._element.matches(INTERACTIVE_FIELDSET_UTIL_TARGET_SELECTOR)) {
|
||||
this.target = this._element;
|
||||
}
|
||||
|
||||
this.childInputs = Array.from(this._element.querySelectorAll(INTERACTIVE_FIELDSET_CHILD_SELECTOR));
|
||||
|
||||
// add event listener
|
||||
const observer = new MutationObserver(() => this._updateVisibility());
|
||||
observer.observe(this.conditionalInput, { attributes: true, attributeFilter: ['disabled'] });
|
||||
this.conditionalInput.addEventListener('input', () => this._updateVisibility());
|
||||
|
||||
// initial visibility update
|
||||
this._updateVisibility();
|
||||
|
||||
// mark as initialized
|
||||
this._element.classList.add(INTERACTIVE_FIELDSET_INITIALIZED_CLASS);
|
||||
|
||||
}
|
||||
|
||||
destroy() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
_updateVisibility() {
|
||||
const active = this._matchesConditionalValue() && !this.conditionalInput.disabled;
|
||||
|
||||
this.target.classList.toggle('hidden', !active);
|
||||
|
||||
this.childInputs.forEach((el) => {
|
||||
el.disabled = !active;
|
||||
|
||||
// disable input for flatpickrs added input as well if exists
|
||||
if (el._flatpickr) {
|
||||
el._flatpickr.altInput.disabled = !active;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_matchesConditionalValue() {
|
||||
if (this._isCheckbox()) {
|
||||
return this.conditionalInput.checked === true;
|
||||
}
|
||||
|
||||
return this.conditionalInput.value === this.conditionalValue;
|
||||
}
|
||||
|
||||
_isCheckbox() {
|
||||
return this.conditionalInput.getAttribute('type') === 'checkbox';
|
||||
}
|
||||
}
|
||||
35
frontend/src/utils/form/interactive-fieldset.md
Normal file
35
frontend/src/utils/form/interactive-fieldset.md
Normal file
@ -0,0 +1,35 @@
|
||||
# Interactive Fieldset Utility
|
||||
Shows/hides inputs based on value of particular input
|
||||
|
||||
## Attribute: `uw-interactive-fieldset`
|
||||
|
||||
## Params:
|
||||
- `data-conditional-input: string`\
|
||||
Selector for the input that this fieldset watches for changes
|
||||
- `data-conditional-value: string`\
|
||||
The value the conditional input needs to be set to for this fieldset to be shown. Can be omitted if conditionalInput is a checkbox
|
||||
|
||||
## Example usage:
|
||||
### example with text input
|
||||
```html
|
||||
<input id='input-0' type='text'>
|
||||
<fieldset uw-interactive-fieldset data-conditional-input='#input-0' data-conditional-value='yes'>...</fieldset>
|
||||
<fieldset uw-interactive-fieldset data-conditional-input='#input-0' data-conditional-value='no'>...</fieldset>
|
||||
```
|
||||
|
||||
### example with `<select>`
|
||||
```html
|
||||
<select id='select-0'>
|
||||
<option value='0'>Zero
|
||||
<option value='1'>One
|
||||
<fieldset uw-interactive-fieldset data-conditional-input='#select-0' data-conditional-value='0'>...</fieldset>
|
||||
<fieldset uw-interactive-fieldset data-conditional-input='#select-0' data-conditional-value='1'>...</fieldset>
|
||||
```
|
||||
|
||||
### example with checkbox
|
||||
```html
|
||||
<input id='checkbox-0' type='checkbox'>
|
||||
<input id='checkbox-1' type='checkbox'>
|
||||
<fieldset uw-interactive-fieldset data-conditional-input='#checkbox-0'>...</fieldset>
|
||||
<fieldset uw-interactive-fieldset data-conditional-input='#checkbox-1'>...</fieldset>
|
||||
```
|
||||
71
frontend/src/utils/form/navigate-away-prompt.js
Normal file
71
frontend/src/utils/form/navigate-away-prompt.js
Normal file
@ -0,0 +1,71 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
import { AUTO_SUBMIT_BUTTON_UTIL_SELECTOR } from './auto-submit-button';
|
||||
import { AUTO_SUBMIT_INPUT_UTIL_SELECTOR } from './auto-submit-input';
|
||||
|
||||
const NAVIGATE_AWAY_PROMPT_INITIALIZED_CLASS = 'navigate-away-prompt--initialized';
|
||||
const NAVIGATE_AWAY_PROMPT_UTIL_OPTOUT = '[uw-no-navigate-away-prompt]';
|
||||
|
||||
@Utility({
|
||||
selector: 'form',
|
||||
})
|
||||
export class NavigateAwayPrompt {
|
||||
|
||||
_element;
|
||||
|
||||
_touched = false;
|
||||
_unloadDueToSubmit = false;
|
||||
|
||||
constructor(element) {
|
||||
if (!element) {
|
||||
throw new Error('Navigate Away Prompt utility needs to be passed an element!');
|
||||
}
|
||||
|
||||
this._element = element;
|
||||
|
||||
if (this._element.classList.contains(NAVIGATE_AWAY_PROMPT_INITIALIZED_CLASS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ignore forms that get submitted automatically
|
||||
if (this._element.querySelector(AUTO_SUBMIT_BUTTON_UTIL_SELECTOR) || this._element.querySelector(AUTO_SUBMIT_INPUT_UTIL_SELECTOR)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this._element.matches(NAVIGATE_AWAY_PROMPT_UTIL_OPTOUT)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', this._beforeUnloadHandler);
|
||||
|
||||
this._element.addEventListener('submit', () => {
|
||||
this._unloadDueToSubmit = true;
|
||||
});
|
||||
this._element.addEventListener('change', () => {
|
||||
this._touched = true;
|
||||
this._unloadDueToSubmit = false;
|
||||
});
|
||||
|
||||
// mark initialized
|
||||
this._element.classList.add(NAVIGATE_AWAY_PROMPT_INITIALIZED_CLASS);
|
||||
|
||||
}
|
||||
|
||||
destroy() {
|
||||
window.removeEventListener('beforeunload', this._beforeUnloadHandler);
|
||||
}
|
||||
|
||||
_beforeUnloadHandler = (event) => {
|
||||
// allow the event to happen if the form was not touched by the
|
||||
// user or the unload event was initiated by a form submit
|
||||
if (!this._touched || this._unloadDueToSubmit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// cancel the unload event. This is the standard to force the prompt to appear.
|
||||
event.preventDefault();
|
||||
// chrome
|
||||
event.returnValue = true;
|
||||
// for all non standard compliant browsers we return a truthy value to activate the prompt.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
12
frontend/src/utils/form/navigate-away-prompt.md
Normal file
12
frontend/src/utils/form/navigate-away-prompt.md
Normal file
@ -0,0 +1,12 @@
|
||||
# Navigate Away Prompt Utility
|
||||
This utility asks the user if (s)he really wants to navigate away from a page containing a form if (s)he already touched an input.
|
||||
|
||||
- Form-Submits will not trigger the prompt.
|
||||
- Utility will ignore forms that contain auto submit elements (buttons, inputs).
|
||||
|
||||
## Attribute: (none)
|
||||
(automatically setup on all form tags that dont automatically submit, see AutoSubmitButtonUtil)
|
||||
(Does not setup on forms that have uw-no-navigate-away-prompt)
|
||||
|
||||
## Example usage:
|
||||
(any page with a form)
|
||||
85
frontend/src/utils/form/reactive-submit-button.js
Normal file
85
frontend/src/utils/form/reactive-submit-button.js
Normal file
@ -0,0 +1,85 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
|
||||
var REACTIVE_SUBMIT_BUTTON_INITIALIZED_CLASS = 'reactive-submit-button--initialized';
|
||||
|
||||
@Utility({
|
||||
selector: 'form',
|
||||
})
|
||||
export class ReactiveSubmitButton {
|
||||
|
||||
_element;
|
||||
|
||||
_requiredInputs;
|
||||
_submitButton;
|
||||
|
||||
constructor(element) {
|
||||
if (!element) {
|
||||
throw new Error('Reactive Submit Button utility cannot be setup without an element!');
|
||||
}
|
||||
|
||||
this._element = element;
|
||||
|
||||
if (this._element.classList.contains(REACTIVE_SUBMIT_BUTTON_INITIALIZED_CLASS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// abort if form has param data-formnorequired
|
||||
if (this._element.dataset.formnorequired !== undefined) {
|
||||
throw new Error('Form has formnorequired data attribute. Will skip setup of reactive submit button.');
|
||||
}
|
||||
|
||||
this._requiredInputs = Array.from(this._element.querySelectorAll('[required]'));
|
||||
if (!this._requiredInputs) {
|
||||
// abort if form has no required inputs
|
||||
throw new Error('Submit button has formnorequired data attribute. Will skip setup of reactive submit button.');
|
||||
}
|
||||
|
||||
const submitButtons = Array.from(this._element.querySelectorAll('[type="submit"]'));
|
||||
if (!submitButtons || !submitButtons.length) {
|
||||
throw new Error('Reactive Submit Button utility couldn\'t find any submit buttons!');
|
||||
}
|
||||
this._submitButton = submitButtons.reverse()[0];
|
||||
// abort if form has param data-formnorequired
|
||||
if (this._submitButton.dataset.formnorequired !== undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.setupInputs();
|
||||
this.updateButtonState();
|
||||
|
||||
this._element.classList.add(REACTIVE_SUBMIT_BUTTON_INITIALIZED_CLASS);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
setupInputs() {
|
||||
this._requiredInputs.forEach((el) => {
|
||||
var checkbox = el.getAttribute('type') === 'checkbox';
|
||||
var eventType = checkbox ? 'change' : 'input';
|
||||
el.addEventListener(eventType, () => {
|
||||
this.updateButtonState();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
updateButtonState() {
|
||||
if (this.inputsValid()) {
|
||||
this._submitButton.removeAttribute('disabled');
|
||||
} else {
|
||||
this._submitButton.setAttribute('disabled', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
inputsValid() {
|
||||
var done = true;
|
||||
this._requiredInputs.forEach((inp) => {
|
||||
var len = inp.value.trim().length;
|
||||
if (done && len === 0) {
|
||||
done = false;
|
||||
}
|
||||
});
|
||||
return done;
|
||||
}
|
||||
}
|
||||
17
frontend/src/utils/form/reactive-submit-button.md
Normal file
17
frontend/src/utils/form/reactive-submit-button.md
Normal file
@ -0,0 +1,17 @@
|
||||
# Reactive Submit Button Utility
|
||||
Disables a forms LAST sumit button as long as the required inputs are invalid
|
||||
(only checks if the value of the inputs are not empty)
|
||||
|
||||
## Attribute: (none)
|
||||
(automatically setup on all form tags)
|
||||
|
||||
## Params:
|
||||
- `data-formnorequired: string`\
|
||||
If present the submit button will never get disabled
|
||||
|
||||
## Example usage:
|
||||
```html
|
||||
<form uw-reactive-submit-button>
|
||||
<input type='text' required>
|
||||
<button type='submit'>
|
||||
```
|
||||
43
frontend/src/utils/inputs/checkbox.js
Normal file
43
frontend/src/utils/inputs/checkbox.js
Normal file
@ -0,0 +1,43 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
import './checkbox.scss';
|
||||
|
||||
var CHECKBOX_CLASS = 'checkbox';
|
||||
var CHECKBOX_INITIALIZED_CLASS = 'checkbox--initialized';
|
||||
|
||||
@Utility({
|
||||
selector: 'input[type="checkbox"]',
|
||||
})
|
||||
export class Checkbox {
|
||||
|
||||
constructor(element) {
|
||||
if (!element) {
|
||||
throw new Error('Checkbox utility cannot be setup without an element!');
|
||||
}
|
||||
|
||||
if (element.classList.contains(CHECKBOX_INITIALIZED_CLASS)) {
|
||||
// throw new Error('Checkbox utility already initialized!');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (element.parentElement.classList.contains(CHECKBOX_CLASS)) {
|
||||
// throw new Error('Checkbox element\'s wrapper already has class '' + CHECKBOX_CLASS + ''!');
|
||||
return false;
|
||||
}
|
||||
|
||||
var siblingEl = element.nextSibling;
|
||||
var parentEl = element.parentElement;
|
||||
|
||||
var wrapperEl = document.createElement('div');
|
||||
wrapperEl.classList.add(CHECKBOX_CLASS);
|
||||
|
||||
var labelEl = document.createElement('label');
|
||||
labelEl.setAttribute('for', element.id);
|
||||
|
||||
wrapperEl.appendChild(element);
|
||||
wrapperEl.appendChild(labelEl);
|
||||
|
||||
parentEl.insertBefore(wrapperEl, siblingEl);
|
||||
|
||||
element.classList.add(CHECKBOX_INITIALIZED_CLASS);
|
||||
}
|
||||
}
|
||||
10
frontend/src/utils/inputs/checkbox.md
Normal file
10
frontend/src/utils/inputs/checkbox.md
Normal file
@ -0,0 +1,10 @@
|
||||
# Checkbox Utility
|
||||
Wraps native checkbox
|
||||
|
||||
## Attribute: (none)
|
||||
(element must be an input of type='checkbox')
|
||||
|
||||
## Example usage:
|
||||
```html
|
||||
<input type='checkbox'>
|
||||
```
|
||||
@ -5,7 +5,7 @@
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
||||
[type="checkbox"] {
|
||||
[type='checkbox'] {
|
||||
position: fixed;
|
||||
top: -1px;
|
||||
left: -1px;
|
||||
@ -16,8 +16,8 @@
|
||||
|
||||
label {
|
||||
display: block;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
background-color: #f3f3f3;
|
||||
box-shadow: inset 0 1px 2px 1px rgba(50, 50, 50, 0.05);
|
||||
border: 2px solid var(--color-primary);
|
||||
@ -41,7 +41,7 @@
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
[type="checkbox"]:focus + label {
|
||||
[type='checkbox']:focus + label {
|
||||
border-color: #3273dc;
|
||||
box-shadow: 0 0 0 0.125em rgba(50,115,220,.25);
|
||||
outline: 0;
|
||||
@ -55,14 +55,16 @@
|
||||
:checked + label::before {
|
||||
background-color: white;
|
||||
transform: rotate(45deg);
|
||||
left: 4px;
|
||||
left: 2px;
|
||||
top: 11px;
|
||||
}
|
||||
|
||||
:checked + label::after {
|
||||
background-color: white;
|
||||
transform: rotate(-45deg);
|
||||
top: 11px;
|
||||
width: 13px;
|
||||
top: 9px;
|
||||
width: 12px;
|
||||
left: 7px;
|
||||
}
|
||||
|
||||
[disabled] + label {
|
||||
@ -72,3 +74,9 @@
|
||||
filter: grayscale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* special treatment for checkboxes in table headers */
|
||||
th .checkbox {
|
||||
margin-right: 7px;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
99
frontend/src/utils/inputs/file-input.js
Normal file
99
frontend/src/utils/inputs/file-input.js
Normal file
@ -0,0 +1,99 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
|
||||
const FILE_INPUT_CLASS = 'file-input';
|
||||
const FILE_INPUT_INITIALIZED_CLASS = 'file-input--initialized';
|
||||
const FILE_INPUT_LIST_CLASS = 'file-input__list';
|
||||
const FILE_INPUT_UNPACK_CHECKBOX_CLASS = 'file-input__unpack';
|
||||
const FILE_INPUT_LABEL_CLASS = 'file-input__label';
|
||||
|
||||
@Utility({
|
||||
selector: 'input[type="file"][uw-file-input]',
|
||||
})
|
||||
export class FileInput {
|
||||
|
||||
_element;
|
||||
_app;
|
||||
|
||||
_isMultiFileInput = false;
|
||||
_fileList;
|
||||
_label;
|
||||
|
||||
constructor(element, app) {
|
||||
if (!element) {
|
||||
throw new Error('FileInput utility cannot be setup without an element!');
|
||||
}
|
||||
|
||||
this._element = element;
|
||||
this._app = app;
|
||||
|
||||
if (this._element.classList.contains(FILE_INPUT_INITIALIZED_CLASS)) {
|
||||
throw new Error('FileInput utility already initialized!');
|
||||
}
|
||||
|
||||
// check if is multi-file input
|
||||
this._isMultiFileInput = this._element.hasAttribute('multiple');
|
||||
if (this._isMultiFileInput) {
|
||||
this._fileList = this._createFileList();
|
||||
}
|
||||
|
||||
this._label = this._createFileLabel();
|
||||
this._updateLabel();
|
||||
|
||||
// add change listener
|
||||
this._element.addEventListener('change', () => {
|
||||
this._updateLabel();
|
||||
this._renderFileList();
|
||||
});
|
||||
|
||||
// add util class for styling and mark as initialized
|
||||
this._element.classList.add(FILE_INPUT_CLASS);
|
||||
this._element.classList.add(FILE_INPUT_INITIALIZED_CLASS);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
_renderFileList() {
|
||||
if (!this._fileList) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = this._element.files;
|
||||
this._fileList.innerHTML = '';
|
||||
Array.from(files).forEach((file) => {
|
||||
const fileDisplayEl = document.createElement('li');
|
||||
fileDisplayEl.innerHTML = file.name;
|
||||
this._fileList.appendChild(fileDisplayEl);
|
||||
});
|
||||
}
|
||||
|
||||
_createFileList() {
|
||||
const list = document.createElement('ol');
|
||||
list.classList.add(FILE_INPUT_LIST_CLASS);
|
||||
const unpackEl = this._element.parentElement.querySelector('.' + FILE_INPUT_UNPACK_CHECKBOX_CLASS);
|
||||
if (unpackEl) {
|
||||
this._element.parentElement.insertBefore(list, unpackEl);
|
||||
} else {
|
||||
this._element.parentElement.appendChild(list);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
_createFileLabel() {
|
||||
const label = document.createElement('label');
|
||||
label.classList.add(FILE_INPUT_LABEL_CLASS);
|
||||
label.setAttribute('for', this._element.id);
|
||||
this._element.parentElement.insertBefore(label, this._element);
|
||||
return label;
|
||||
}
|
||||
|
||||
_updateLabel() {
|
||||
const files = this._element.files;
|
||||
if (files && files.length) {
|
||||
this._label.innerText = this._isMultiFileInput ? files.length + ' ' + this._app.i18n.get('filesSelected') : files[0].name;
|
||||
} else {
|
||||
this._label.innerText = this._isMultiFileInput ? this._app.i18n.get('selectFiles') : this._app.i18n.get('selectFile');
|
||||
}
|
||||
}
|
||||
}
|
||||
23
frontend/src/utils/inputs/file-input.md
Normal file
23
frontend/src/utils/inputs/file-input.md
Normal file
@ -0,0 +1,23 @@
|
||||
# FileInput Utility
|
||||
Wraps native file input
|
||||
|
||||
## Attribute: `uw-file-input`
|
||||
(element must be an input of type='file')
|
||||
|
||||
## Example usage:
|
||||
```html
|
||||
<input type='file' uw-file-input>
|
||||
```
|
||||
|
||||
## Internationalization:
|
||||
This utility expects the following translations to be available:
|
||||
- `filesSelected`:\
|
||||
label of multi-input button after selection\
|
||||
*example*: 'Dateien ausgewählt' (will be prepended by number of selected files)
|
||||
- `selectFile`:\
|
||||
label of single-input button before selection\
|
||||
*example*: 'Datei auswählen'
|
||||
- `selectFiles`:\
|
||||
label of multi-input button before selection\
|
||||
*example*: 'Datei(en) auswählen'
|
||||
|
||||
10
frontend/src/utils/inputs/inputs.js
Normal file
10
frontend/src/utils/inputs/inputs.js
Normal file
@ -0,0 +1,10 @@
|
||||
import { Checkbox } from './checkbox';
|
||||
import { FileInput } from './file-input';
|
||||
|
||||
import './inputs.scss';
|
||||
import './radio.scss';
|
||||
|
||||
export const InputUtils = [
|
||||
Checkbox,
|
||||
FileInput,
|
||||
];
|
||||
@ -85,14 +85,14 @@
|
||||
}
|
||||
|
||||
/* TEXT INPUTS */
|
||||
input[type="text"],
|
||||
input[type="search"],
|
||||
input[type="password"],
|
||||
input[type="url"],
|
||||
input[type="number"],
|
||||
input[type="email"],
|
||||
input[type*="date"],
|
||||
input[type*="time"],
|
||||
input[type='text'],
|
||||
input[type='search'],
|
||||
input[type='password'],
|
||||
input[type='url'],
|
||||
input[type='number'],
|
||||
input[type='email'],
|
||||
input[type*='date'],
|
||||
input[type*='time'],
|
||||
select {
|
||||
/* from bulma.css */
|
||||
color: #363636;
|
||||
@ -111,13 +111,13 @@ select {
|
||||
padding: 4px 13px;
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
input[type='number'] {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
input[type*="date"],
|
||||
input[type*="time"],
|
||||
.flatpickr-input[type="text"] {
|
||||
input[type*='date'],
|
||||
input[type*='time'],
|
||||
.flatpickr-input[type='text'] {
|
||||
width: 50%;
|
||||
width: 250px;
|
||||
}
|
||||
@ -195,7 +195,11 @@ option {
|
||||
}
|
||||
}
|
||||
|
||||
/* CUSTOM FILE INPUT */
|
||||
/* FILE INPUT */
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-input__label {
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
@ -205,6 +209,31 @@ option {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.file-input__input--hidden {
|
||||
display: none;
|
||||
.file-input__info {
|
||||
font-size: .9rem;
|
||||
font-style: italic;
|
||||
margin: 10px 0;
|
||||
color: var(--color-fontsec);
|
||||
}
|
||||
|
||||
.file-input__list {
|
||||
margin-left: 40px;
|
||||
margin-top: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* PREVIOUSLY UPLOADED FILES */
|
||||
|
||||
.file-uploads-label {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.file-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.checkbox {
|
||||
margin-left: 12px;
|
||||
}
|
||||
}
|
||||
@ -5,15 +5,11 @@
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.radio-group__option {
|
||||
min-width: 30px;
|
||||
}
|
||||
|
||||
.radio {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
||||
[type="radio"] {
|
||||
[type='radio'] {
|
||||
position: fixed;
|
||||
top: -1px;
|
||||
left: -1px;
|
||||
197
frontend/src/utils/mass-input/mass-input.js
Normal file
197
frontend/src/utils/mass-input/mass-input.js
Normal file
@ -0,0 +1,197 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
|
||||
const MASS_INPUT_CELL_SELECTOR = '.massinput__cell';
|
||||
const MASS_INPUT_ADD_CELL_SELECTOR = '.massinput__cell--add';
|
||||
const MASS_INPUT_SUBMIT_BUTTON_CLASS = 'massinput__submit-button';
|
||||
const MASS_INPUT_INITIALIZED_CLASS = 'mass-input--initialized';
|
||||
|
||||
@Utility({
|
||||
selector: '[uw-mass-input]',
|
||||
})
|
||||
export class MassInput {
|
||||
|
||||
_element;
|
||||
_app;
|
||||
|
||||
_massInputId;
|
||||
_massInputFormSubmitHandler;
|
||||
_massInputForm;
|
||||
|
||||
constructor(element, app) {
|
||||
if (!element) {
|
||||
throw new Error('Mass Input utility cannot be setup without an element!');
|
||||
}
|
||||
|
||||
this._element = element;
|
||||
this._app = app;
|
||||
|
||||
if (this._element.classList.contains(MASS_INPUT_INITIALIZED_CLASS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._massInputId = this._element.dataset.massInputIdent || '_';
|
||||
this._massInputForm = this._element.closest('form');
|
||||
|
||||
if (!this._massInputForm) {
|
||||
throw new Error('Mass Input utility cannot be setup without being wrapped in a <form>!');
|
||||
}
|
||||
|
||||
this._massInputFormSubmitHandler = this._makeSubmitHandler();
|
||||
|
||||
// setup submit buttons inside this massinput so browser
|
||||
// uses correct submit button for form submission.
|
||||
const buttons = this._getMassInputSubmitButtons();
|
||||
buttons.forEach((button) => {
|
||||
this._setupSubmitButton(button);
|
||||
});
|
||||
|
||||
this._massInputForm.addEventListener('submit', this._massInputFormSubmitHandler);
|
||||
this._massInputForm.addEventListener('keypress', this._keypressHandler);
|
||||
|
||||
// mark initialized
|
||||
this._element.classList.add(MASS_INPUT_INITIALIZED_CLASS);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._reset();
|
||||
}
|
||||
|
||||
_makeSubmitHandler() {
|
||||
const method = this._massInputForm.getAttribute('method') || 'POST';
|
||||
const url = this._massInputForm.getAttribute('action') || window.location.href;
|
||||
const enctype = this._massInputForm.getAttribute('enctype') || 'application/json';
|
||||
|
||||
let requestFn;
|
||||
if (this._app.httpClient[method.toLowerCase()]) {
|
||||
requestFn = this._app.httpClient[method.toLowerCase()].bind(this._app.httpClient);
|
||||
}
|
||||
|
||||
return (event) => {
|
||||
let activeElement;
|
||||
|
||||
// check if event occured from either a mass input add/delete button or
|
||||
// from inside one of massinput's inputs (i.e. a child is focused/active)
|
||||
activeElement = this._element.querySelector(':focus, :active');
|
||||
|
||||
if (!activeElement) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// find the according massinput cell thats hosts the element that triggered the submit
|
||||
const massInputCell = activeElement.closest(MASS_INPUT_CELL_SELECTOR);
|
||||
if (!massInputCell) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const submitButton = massInputCell.querySelector('.' + MASS_INPUT_SUBMIT_BUTTON_CLASS);
|
||||
if (!submitButton) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isAddCell = massInputCell.matches(MASS_INPUT_ADD_CELL_SELECTOR);
|
||||
const submitButtonIsActive = submitButton.matches(':focus, :active');
|
||||
// if the cell is not an add cell the active element must at least be the cells submit button
|
||||
if (!isAddCell && !submitButtonIsActive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const requestBody = this._serializeForm(submitButton, enctype);
|
||||
|
||||
if (requestFn && requestBody) {
|
||||
const headers = {'Mass-Input-Shortcircuit': this._massInputId};
|
||||
|
||||
if (enctype !== 'multipart/form-data') {
|
||||
headers['Content-Type'] = enctype;
|
||||
}
|
||||
|
||||
requestFn({
|
||||
url: url,
|
||||
headers: headers,
|
||||
body: requestBody,
|
||||
}).then((response) => {
|
||||
return this._app.htmlHelpers.parseResponse(response);
|
||||
}).then((response) => {
|
||||
this._processResponse(response.element);
|
||||
if (isAddCell) {
|
||||
this._reFocusAddCell();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
_keypressHandler = (event) => {
|
||||
if (event.keyCode !== 13) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this._massInputFormSubmitHandler) {
|
||||
return this._massInputFormSubmitHandler(event);
|
||||
}
|
||||
}
|
||||
|
||||
_getMassInputSubmitButtons() {
|
||||
return Array.from(this._element.querySelectorAll('button[type="submit"][name][value], .' + MASS_INPUT_SUBMIT_BUTTON_CLASS));
|
||||
}
|
||||
|
||||
_setupSubmitButton(button) {
|
||||
button.setAttribute('type', 'button');
|
||||
button.classList.add(MASS_INPUT_SUBMIT_BUTTON_CLASS);
|
||||
button.addEventListener('click', this._massInputFormSubmitHandler);
|
||||
}
|
||||
|
||||
_resetSubmitButton(button) {
|
||||
button.setAttribute('type', 'submit');
|
||||
button.classList.remove(MASS_INPUT_SUBMIT_BUTTON_CLASS);
|
||||
button.removeEventListener('click', this._massInputFormSubmitHandler);
|
||||
}
|
||||
|
||||
_processResponse(responseElement) {
|
||||
this._element.innerHTML = '';
|
||||
this._element.appendChild(responseElement);
|
||||
|
||||
this._reset();
|
||||
|
||||
this._app.utilRegistry.setupAll(this._element);
|
||||
}
|
||||
|
||||
_serializeForm(submitButton, enctype) {
|
||||
const formData = new FormData(this._massInputForm);
|
||||
|
||||
// manually add name and value of submit button to formData
|
||||
formData.append(submitButton.name, submitButton.value);
|
||||
|
||||
if (enctype === 'application/x-www-form-urlencoded') {
|
||||
return new URLSearchParams(formData);
|
||||
} else if (enctype === 'multipart/form-data') {
|
||||
return formData;
|
||||
} else {
|
||||
throw new Error('Unsupported form enctype: ' + enctype);
|
||||
}
|
||||
}
|
||||
|
||||
_reFocusAddCell() {
|
||||
const addCell = this._element.querySelector(MASS_INPUT_ADD_CELL_SELECTOR);
|
||||
if (!addCell) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const addCellInput = addCell.querySelector('input:not([type="hidden"])');
|
||||
if (addCellInput) {
|
||||
// Clearing of add-inputs is done in the backend
|
||||
addCellInput.focus();
|
||||
}
|
||||
}
|
||||
|
||||
_reset() {
|
||||
this._element.classList.remove(MASS_INPUT_INITIALIZED_CLASS);
|
||||
this._massInputForm.removeEventListener('submit', this._massInputFormSubmitHandler);
|
||||
this._massInputForm.removeEventListener('keypress', this._keypressHandler);
|
||||
|
||||
const buttons = this._getMassInputSubmitButtons();
|
||||
buttons.forEach((button) => {
|
||||
this._resetSubmitButton(button);
|
||||
});
|
||||
}
|
||||
}
|
||||
17
frontend/src/utils/mass-input/mass-input.md
Normal file
17
frontend/src/utils/mass-input/mass-input.md
Normal file
@ -0,0 +1,17 @@
|
||||
# Mass Input Utility
|
||||
Allows form shapes to be manipulated asynchronously.
|
||||
|
||||
Will asynchronously submit the containing form and replace the contents of the mass input element with the one from the BE response.
|
||||
|
||||
The utility will only trigger an AJAX request if the mass input element has an active/focused element whilst the form is being submitted.
|
||||
|
||||
## Attribute: `uw-mass-input`
|
||||
|
||||
## Example usage:
|
||||
```html
|
||||
<form method='POST' action='...'>
|
||||
<input type='text'>
|
||||
<div uw-mass-input>
|
||||
<input type='text'>
|
||||
<button type='submit'>
|
||||
```
|
||||
182
frontend/src/utils/modal/modal.js
Normal file
182
frontend/src/utils/modal/modal.js
Normal file
@ -0,0 +1,182 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
import './modal.scss';
|
||||
|
||||
const MODAL_HEADERS = {
|
||||
'Is-Modal': 'True',
|
||||
};
|
||||
|
||||
const MODAL_INITIALIZED_CLASS = 'modal--initialized';
|
||||
const MODAL_CLASS = 'modal';
|
||||
const MODAL_OPEN_CLASS = 'modal--open';
|
||||
const MODAL_TRIGGER_CLASS = 'modal__trigger';
|
||||
const MODAL_CONTENT_CLASS = 'modal__content';
|
||||
const MODAL_OVERLAY_CLASS = 'modal__overlay';
|
||||
const MODAL_OVERLAY_OPEN_CLASS = 'modal__overlay--open';
|
||||
const MODAL_CLOSER_CLASS = 'modal__closer';
|
||||
|
||||
const MAIN_CONTENT_CLASS = 'main__content-body';
|
||||
|
||||
// one singleton wrapper to keep all the modals to avoid CSS bug
|
||||
// with blurry text due to `transform: translate(-50%, -50%)`
|
||||
// will be created (and reused) for the first modal that gets initialized
|
||||
const MODALS_WRAPPER_CLASS = 'modals-wrapper';
|
||||
const MODALS_WRAPPER_SELECTOR = '.' + MODALS_WRAPPER_CLASS;
|
||||
const MODALS_WRAPPER_OPEN_CLASS = 'modals-wrapper--open';
|
||||
|
||||
@Utility({
|
||||
selector: '[uw-modal]',
|
||||
})
|
||||
export class Modal {
|
||||
|
||||
_element;
|
||||
_app;
|
||||
|
||||
_modalsWrapper;
|
||||
_modalOverlay;
|
||||
_modalUrl;
|
||||
|
||||
constructor(element, app) {
|
||||
if (!element) {
|
||||
throw new Error('Modal utility cannot be setup without an element!');
|
||||
}
|
||||
|
||||
this._element = element;
|
||||
this._app = app;
|
||||
|
||||
if (this._element.classList.contains(MODAL_INITIALIZED_CLASS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._ensureModalWrapper();
|
||||
|
||||
// param modalTrigger
|
||||
if (!this._element.dataset.modalTrigger) {
|
||||
throw new Error('Modal utility cannot be setup without a trigger element!');
|
||||
} else {
|
||||
this._setupTrigger();
|
||||
}
|
||||
|
||||
// param modalCloseable
|
||||
if (this._element.dataset.modalCloseable !== undefined) {
|
||||
this._setupCloser();
|
||||
}
|
||||
|
||||
// mark as initialized and add modal class for styling
|
||||
this._element.classList.add(MODAL_INITIALIZED_CLASS, MODAL_CLASS);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
_ensureModalWrapper() {
|
||||
this._modalsWrapper = document.querySelector(MODALS_WRAPPER_SELECTOR);
|
||||
if (!this._modalsWrapper) {
|
||||
// create modal wrapper
|
||||
this._modalsWrapper = document.createElement('div');
|
||||
this._modalsWrapper.classList.add(MODALS_WRAPPER_CLASS);
|
||||
document.body.appendChild(this._modalsWrapper);
|
||||
}
|
||||
|
||||
this._modalOverlay = this._modalsWrapper.querySelector('.' + MODAL_OVERLAY_CLASS);
|
||||
if (!this._modalOverlay) {
|
||||
// create modal overlay
|
||||
this._modalOverlay = document.createElement('div');
|
||||
this._modalOverlay.classList.add(MODAL_OVERLAY_CLASS);
|
||||
this._modalsWrapper.appendChild(this._modalOverlay);
|
||||
}
|
||||
}
|
||||
|
||||
_setupTrigger() {
|
||||
let triggerSelector = this._element.dataset.modalTrigger;
|
||||
if (!triggerSelector.startsWith('#')) {
|
||||
triggerSelector = '#' + triggerSelector;
|
||||
}
|
||||
const triggerElement = document.querySelector(triggerSelector);
|
||||
|
||||
if (!triggerElement) {
|
||||
throw new Error('Trigger element for Modal not found: "' + triggerSelector + '"');
|
||||
}
|
||||
|
||||
triggerElement.classList.add(MODAL_TRIGGER_CLASS);
|
||||
triggerElement.addEventListener('click', this._onTriggerClicked, false);
|
||||
this._modalUrl = triggerElement.getAttribute('href');
|
||||
}
|
||||
|
||||
_setupCloser() {
|
||||
const closerElement = document.createElement('div');
|
||||
this._element.insertBefore(closerElement, null);
|
||||
closerElement.classList.add(MODAL_CLOSER_CLASS);
|
||||
closerElement.addEventListener('click', this._onCloseClicked, false);
|
||||
this._modalOverlay.addEventListener('click', this._onCloseClicked, false);
|
||||
}
|
||||
|
||||
_onTriggerClicked = (event) => {
|
||||
event.preventDefault();
|
||||
this._open();
|
||||
}
|
||||
|
||||
_onCloseClicked = (event) => {
|
||||
event.preventDefault();
|
||||
this._close();
|
||||
}
|
||||
|
||||
_onKeyUp = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
this._close();
|
||||
}
|
||||
}
|
||||
|
||||
_open() {
|
||||
this._element.classList.add(MODAL_OPEN_CLASS);
|
||||
this._modalOverlay.classList.add(MODAL_OVERLAY_OPEN_CLASS);
|
||||
this._modalsWrapper.classList.add(MODALS_WRAPPER_OPEN_CLASS);
|
||||
this._modalsWrapper.appendChild(this._element);
|
||||
|
||||
if (this._modalUrl) {
|
||||
this._fillModal(this._modalUrl);
|
||||
}
|
||||
|
||||
document.addEventListener('keyup', this._onKeyUp);
|
||||
}
|
||||
|
||||
_close() {
|
||||
this._modalOverlay.classList.remove(MODAL_OVERLAY_OPEN_CLASS);
|
||||
this._element.classList.remove(MODAL_OPEN_CLASS);
|
||||
this._modalsWrapper.classList.remove(MODALS_WRAPPER_OPEN_CLASS);
|
||||
|
||||
document.removeEventListener('keyup', this._onKeyUp);
|
||||
};
|
||||
|
||||
_fillModal(url) {
|
||||
this._app.httpClient.get({
|
||||
url: url,
|
||||
headers: MODAL_HEADERS,
|
||||
}).then(
|
||||
(response) => this._app.htmlHelpers.parseResponse(response)
|
||||
).then(
|
||||
(response) => this._processResponse(response.element)
|
||||
);
|
||||
}
|
||||
|
||||
_processResponse(responseElement) {
|
||||
const modalContent = document.createElement('div');
|
||||
modalContent.classList.add(MODAL_CONTENT_CLASS);
|
||||
|
||||
const contentBody = responseElement.querySelector('.' + MAIN_CONTENT_CLASS);
|
||||
|
||||
if (contentBody) {
|
||||
modalContent.innerHTML = contentBody.innerHTML;
|
||||
}
|
||||
|
||||
const previousModalContent = this._element.querySelector('.' + MODAL_CONTENT_CLASS);
|
||||
if (previousModalContent) {
|
||||
previousModalContent.remove();
|
||||
}
|
||||
|
||||
this._element.insertBefore(modalContent, null);
|
||||
|
||||
// setup any newly arrived utils
|
||||
this._app.utilRegistry.setupAll(this._element);
|
||||
}
|
||||
}
|
||||
16
frontend/src/utils/modal/modal.md
Normal file
16
frontend/src/utils/modal/modal.md
Normal file
@ -0,0 +1,16 @@
|
||||
# Modal Utility
|
||||
|
||||
## Attribute: `uw-modal`
|
||||
|
||||
## Params:
|
||||
- `data-modal-trigger: string`\
|
||||
Selector for the element that toggles the modal.
|
||||
If trigger element has 'href' attribute the modal will be dynamically loaded from the referenced page
|
||||
- `data-modal-closeable: boolean`\
|
||||
If the param is present the modal will have a close-icon and can also be closed by clicking anywhere on the overlay
|
||||
|
||||
## Example usage:
|
||||
```html
|
||||
<div uw-modal data-modal-trigger='#trigger' data-modal-closeable>This is the modal content
|
||||
<div id='trigger'>Click me to open the modal
|
||||
```
|
||||
@ -1,30 +1,46 @@
|
||||
.modal {
|
||||
.modals-wrapper {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%) scale(0.8, 0.8);
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&.modals-wrapper--open {
|
||||
z-index: 200;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: relative;
|
||||
display: none;
|
||||
background-color: rgba(255, 255, 255, 1);
|
||||
min-width: 60vw;
|
||||
max-width: 70vw;
|
||||
min-height: 100px;
|
||||
max-height: calc(100vh - 30px);
|
||||
border-radius: 2px;
|
||||
z-index: -1;
|
||||
color: var(--color-font);
|
||||
padding: 0 65px 0 20px;
|
||||
padding: 0 40px;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
transition:
|
||||
opacity .2s .1s ease-in-out,
|
||||
transform .3s ease-in-out;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
|
||||
&.modal--open {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
z-index: 200;
|
||||
transform: translate(-50%, -50%) scale(1, 1);
|
||||
transition:
|
||||
opacity .2s .1s ease-in-out,
|
||||
transform .3s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
87
frontend/src/utils/show-hide/show-hide.js
Normal file
87
frontend/src/utils/show-hide/show-hide.js
Normal file
@ -0,0 +1,87 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
import './show-hide.scss';
|
||||
|
||||
const SHOW_HIDE_LOCAL_STORAGE_KEY = 'SHOW_HIDE';
|
||||
const SHOW_HIDE_INITIALIZED_CLASS = 'show-hide--initialized';
|
||||
const SHOW_HIDE_COLLAPSED_CLASS = 'show-hide--collapsed';
|
||||
const SHOW_HIDE_TOGGLE_CLASS = 'show-hide__toggle';
|
||||
const SHOW_HIDE_TOGGLE_RIGHT_CLASS = 'show-hide__toggle--right';
|
||||
|
||||
@Utility({
|
||||
selector: '[uw-show-hide]',
|
||||
})
|
||||
export class ShowHide {
|
||||
|
||||
_showHideId;
|
||||
_element;
|
||||
|
||||
constructor(element) {
|
||||
if (!element) {
|
||||
throw new Error('ShowHide utility cannot be setup without an element!');
|
||||
}
|
||||
|
||||
this._element = element;
|
||||
|
||||
if (this._element.classList.contains(SHOW_HIDE_INITIALIZED_CLASS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// register click listener
|
||||
this._addClickListener();
|
||||
|
||||
// param showHideId
|
||||
if (this._element.dataset.showHideId) {
|
||||
this._showHideId = this._element.dataset.showHideId;
|
||||
}
|
||||
|
||||
// param showHideCollapsed
|
||||
let collapsed = false;
|
||||
if (this._element.dataset.showHideCollapsed !== undefined) {
|
||||
collapsed = true;
|
||||
}
|
||||
|
||||
if (this._showHideId) {
|
||||
let localStorageCollapsed = this._getLocalStorage()[this._showHideId];
|
||||
if (typeof localStorageCollapsed !== 'undefined') {
|
||||
collapsed = localStorageCollapsed;
|
||||
}
|
||||
}
|
||||
this._element.parentElement.classList.toggle(SHOW_HIDE_COLLAPSED_CLASS, collapsed);
|
||||
|
||||
// param showHideAlign
|
||||
const alignment = this._element.dataset.showHideAlign;
|
||||
if (alignment === 'right') {
|
||||
this._element.classList.add(SHOW_HIDE_TOGGLE_RIGHT_CLASS);
|
||||
}
|
||||
|
||||
// mark as initialized
|
||||
this._element.classList.add(SHOW_HIDE_INITIALIZED_CLASS, SHOW_HIDE_TOGGLE_CLASS);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._element.removeEventListener('click', this._clickHandler);
|
||||
}
|
||||
|
||||
_addClickListener() {
|
||||
this._element.addEventListener('click', this._clickHandler);
|
||||
}
|
||||
|
||||
_clickHandler = () => {
|
||||
const newState = this._element.parentElement.classList.toggle(SHOW_HIDE_COLLAPSED_CLASS);
|
||||
|
||||
if (this._showHideId) {
|
||||
this._setLocalStorage(this._showHideId, newState);
|
||||
}
|
||||
}
|
||||
|
||||
// maybe move these to a LocalStorageHelper?
|
||||
_setLocalStorage(id, state) {
|
||||
const lsData = this._getLocalStorage();
|
||||
lsData[id] = state;
|
||||
window.localStorage.setItem(SHOW_HIDE_LOCAL_STORAGE_KEY, JSON.stringify(lsData));
|
||||
}
|
||||
|
||||
_getLocalStorage() {
|
||||
return JSON.parse(window.localStorage.getItem(SHOW_HIDE_LOCAL_STORAGE_KEY)) || {};
|
||||
}
|
||||
}
|
||||
21
frontend/src/utils/show-hide/show-hide.md
Normal file
21
frontend/src/utils/show-hide/show-hide.md
Normal file
@ -0,0 +1,21 @@
|
||||
# ShowHide
|
||||
|
||||
Allows to toggle the visibilty of an element by clicking another element.
|
||||
|
||||
## Attribute: `uw-show-hide`
|
||||
|
||||
## Params:
|
||||
- `data-show-hide-id: string` (optional)\
|
||||
If this param is given the state of the utility will be persisted in the clients local storage.
|
||||
- `data-show-hide-collapsed: boolean` (optional)\
|
||||
If this param is present the ShowHide utility will be collapsed. This value will be overruled by any value stored in the LocalStorage.
|
||||
- `data-show-hide-align: 'right'` (optional)\
|
||||
Where to put the arrow that marks the element as a ShowHide toggle. Left of toggle by default.
|
||||
|
||||
## Example usage:
|
||||
```html
|
||||
<div>
|
||||
<div uw-show-hide>Click me
|
||||
<div>This will be toggled
|
||||
<div>This will be toggled as well
|
||||
```
|
||||
@ -1,10 +1,9 @@
|
||||
$show-hide-toggle-size: 6px;
|
||||
|
||||
.js-show-hide__toggle {
|
||||
.show-hide__toggle {
|
||||
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
padding: 3px 7px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-grey-lighter);
|
||||
@ -12,32 +11,39 @@ $show-hide-toggle-size: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.js-show-hide__toggle::before {
|
||||
.show-hide__toggle::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: $show-hide-toggle-size;
|
||||
height: $show-hide-toggle-size;
|
||||
left: -15px;
|
||||
top: 12px - $show-hide-toggle-size / 2;
|
||||
top: 50%;
|
||||
color: var(--color-primary);
|
||||
border-right: 2px solid currentColor;
|
||||
border-top: 2px solid currentColor;
|
||||
transition: transform .2s ease;
|
||||
transform-origin: ($show-hide-toggle-size / 2);
|
||||
transform: translateY($show-hide-toggle-size) rotate(-45deg);
|
||||
transform: translateY(-50%) rotate(-45deg);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
left: auto;
|
||||
right: 20px;
|
||||
color: var(--color-font);
|
||||
}
|
||||
}
|
||||
|
||||
.js-show-hide__target {
|
||||
transition: all .2s ease;
|
||||
.show-hide__toggle--right::before {
|
||||
left: auto;
|
||||
right: 20px;
|
||||
color: var(--color-font);
|
||||
}
|
||||
|
||||
.js-show-hide--collapsed {
|
||||
.show-hide--collapsed {
|
||||
|
||||
.js-show-hide__toggle::before {
|
||||
transform: translateY($show-hide-toggle-size / 3) rotate(135deg);
|
||||
.show-hide__toggle::before {
|
||||
transform: translateY(-50%) rotate(135deg);
|
||||
}
|
||||
|
||||
.js-show-hide__target {
|
||||
:not(.show-hide__toggle) {
|
||||
display: block;
|
||||
height: 0;
|
||||
margin: 0;
|
||||
@ -1,3 +1,5 @@
|
||||
import './tabber.scss';
|
||||
|
||||
(function($) {
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
@ -26,7 +28,7 @@
|
||||
}
|
||||
tab.hide();
|
||||
var loaded = false;
|
||||
tabs.push({index: i, name: tabName, file: tabFile, dom: tab, opener: $opener, loaded: false});
|
||||
tabs.push({index: i, name: tabName, file: tabFile, dom: tab, opener: $opener, loaded: loaded });
|
||||
});
|
||||
|
||||
$this.on('click', 'a[href^="#"]', function(event) {
|
||||
10
frontend/src/utils/tooltips/tooltips.js
Normal file
10
frontend/src/utils/tooltips/tooltips.js
Normal file
@ -0,0 +1,10 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
import './tooltips.scss';
|
||||
|
||||
// empty 'shell' to be able to load styles
|
||||
@Utility({
|
||||
selector: '[not-something-that-would-be-found]',
|
||||
})
|
||||
export class Tooltip {
|
||||
destroy() {}
|
||||
};
|
||||
@ -28,13 +28,21 @@
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
&.tooltip__handle--danger::before {
|
||||
content: '\f12a';
|
||||
}
|
||||
|
||||
&.tooltip__handle--danger {
|
||||
background-color: var(--color-warning);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-light);
|
||||
}
|
||||
26
frontend/src/utils/utils.js
Normal file
26
frontend/src/utils/utils.js
Normal file
@ -0,0 +1,26 @@
|
||||
import { Alerts } from './alerts/alerts';
|
||||
import { Asidenav } from './asidenav/asidenav';
|
||||
import { AsyncForm } from './async-form/async-form';
|
||||
import { ShowHide } from './show-hide/show-hide';
|
||||
import { AsyncTable } from './async-table/async-table';
|
||||
import { CheckAll } from './check-all/check-all';
|
||||
import { FormUtils } from './form/form';
|
||||
import { InputUtils } from './inputs/inputs';
|
||||
import { MassInput } from './mass-input/mass-input';
|
||||
import { Modal } from './modal/modal';
|
||||
import { Tooltip } from './tooltips/tooltips';
|
||||
|
||||
export const Utils = [
|
||||
Alerts,
|
||||
Asidenav,
|
||||
AsyncForm,
|
||||
AsyncTable,
|
||||
CheckAll,
|
||||
ShowHide,
|
||||
...FormUtils,
|
||||
...InputUtils,
|
||||
MassInput,
|
||||
Modal,
|
||||
ShowHide,
|
||||
Tooltip,
|
||||
];
|
||||
@ -1,3 +1,18 @@
|
||||
/*
|
||||
custom code
|
||||
hides the up/down arrows in time (number) inputs
|
||||
*/
|
||||
/* webkit */
|
||||
.flatpickr-calendar input[type=number]::-webkit-inner-spin-button,
|
||||
.flatpickr-calendar input[type=number]::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
/* firefox */
|
||||
.flatpickr-calendar input[type=number] {
|
||||
-moz-appearance:textfield;
|
||||
}
|
||||
/* vendor code */
|
||||
.flatpickr-calendar {
|
||||
background: transparent;
|
||||
opacity: 0;
|
||||
@ -254,7 +269,7 @@
|
||||
}
|
||||
.numInputWrapper span:after {
|
||||
display: block;
|
||||
content: "";
|
||||
content: '';
|
||||
position: absolute;
|
||||
}
|
||||
.numInputWrapper span.arrowUp {
|
||||
@ -628,7 +643,7 @@ span.flatpickr-weekday {
|
||||
display: flex;
|
||||
}
|
||||
.flatpickr-time:after {
|
||||
content: "";
|
||||
content: '';
|
||||
display: table;
|
||||
clear: both;
|
||||
}
|
||||
5
frontend/vendor/fontawesome.css
vendored
Normal file
5
frontend/vendor/fontawesome.css
vendored
Normal file
File diff suppressed because one or more lines are too long
2
frontend/vendor/main.js
vendored
Normal file
2
frontend/vendor/main.js
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
import './fontawesome.css';
|
||||
import './flatpickr.css';
|
||||
13
haddock.sh
13
haddock.sh
@ -1,3 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
exec -- stack build --fast --flag uniworx:library-only --flag uniworx:dev --haddock --haddock-hyperlink-source --haddock-deps --haddock-internal
|
||||
move-back() {
|
||||
mv -v .stack-work .stack-work-doc
|
||||
[[ -d .stack-work-build ]] && mv -v .stack-work-build .stack-work
|
||||
}
|
||||
|
||||
if [[ -d .stack-work-doc ]]; then
|
||||
[[ -d .stack-work ]] && mv -v .stack-work .stack-work-build
|
||||
mv -v .stack-work-doc .stack-work
|
||||
trap move-back EXIT
|
||||
fi
|
||||
|
||||
stack build --fast --flag uniworx:library-only --flag uniworx:dev --haddock --haddock-hyperlink-source --haddock-deps --haddock-internal
|
||||
|
||||
2
hlint.sh
2
hlint.sh
@ -1,3 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
exec -- ./test.sh uniworx:test:hlint
|
||||
exec -- stack build --test --fast --flag uniworx:dev --flag uniworx:library-only uniworx:test:hlint
|
||||
|
||||
80
karma.conf.js
Normal file
80
karma.conf.js
Normal file
@ -0,0 +1,80 @@
|
||||
/* eslint-disable */
|
||||
module.exports = function(config) {
|
||||
config.set({
|
||||
//root path location to resolve paths defined in files and exclude
|
||||
basePath: '',
|
||||
//files/patterns to load in the browser
|
||||
files: ['frontend/src/**/*.spec.js'],
|
||||
|
||||
//executes the tests whenever one of watched files changes
|
||||
autoWatch: true,
|
||||
//if true, Karma will run tests and then exit browser
|
||||
singleRun: true,
|
||||
//if true, Karma fails on running empty test-suites
|
||||
failOnEmptyTestSuite:false,
|
||||
//reduce the kind of information passed to the bash
|
||||
logLevel: config.LOG_WARN, //config.LOG_DISABLE, config.LOG_ERROR, config.LOG_INFO, config.LOG_DEBUG
|
||||
|
||||
//list of frameworks you want to use, only jasmine is installed automatically
|
||||
frameworks: ['jasmine'],
|
||||
//list of browsers to launch and capture
|
||||
browsers: ['ChromeHeadless'],
|
||||
//list of reporters to use
|
||||
reporters: ['mocha','kjhtml'],
|
||||
|
||||
client: {
|
||||
jasmine:{
|
||||
//tells jasmine to run specs in semi random order, false is default
|
||||
random: false
|
||||
}
|
||||
},
|
||||
|
||||
/* karma-webpack config
|
||||
pass your webpack configuration for karma
|
||||
add `babel-loader` to the webpack configuration to make the ES6+ code readable to the browser */
|
||||
webpack: {
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.js$/i,
|
||||
exclude:/(node_modules)/,
|
||||
loader:'babel-loader',
|
||||
options:{
|
||||
presets:['@babel/preset-env']
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.(css|scss)$/i,
|
||||
loader:'null-loader',
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
preprocessors: {
|
||||
//add webpack as preprocessor to support require() in test-suits .js files
|
||||
'./frontend/src/**/*.js': ['webpack']
|
||||
},
|
||||
webpackMiddleware: {
|
||||
//turn off webpack bash output when run the tests
|
||||
noInfo: true,
|
||||
stats: 'errors-only'
|
||||
},
|
||||
customLaunchers: {
|
||||
ChromeHeadless: {
|
||||
base: 'Chrome',
|
||||
flags: [
|
||||
'--headless',
|
||||
'--disable-gpu',
|
||||
'--no-sandbox',
|
||||
// Without a remote debugging port, Google Chrome exits immediately.
|
||||
'--remote-debugging-port=9222',
|
||||
],
|
||||
}
|
||||
},
|
||||
|
||||
/*karma-mocha-reporter config*/
|
||||
mochaReporter: {
|
||||
output: 'noFailures' //full, autowatch, minimal
|
||||
}
|
||||
});
|
||||
};
|
||||
4
messages/frontend/de.msg
Normal file
4
messages/frontend/de.msg
Normal file
@ -0,0 +1,4 @@
|
||||
FilesSelected: Dateien ausgewählt
|
||||
SelectFile: Datei auswählen
|
||||
SelectFiles: Datei(en) auswählen
|
||||
AsyncFormFailure: Da ist etwas schief gelaufen, das tut uns Leid. Falls das erneut passiert schicke uns gerne eine kurze Beschreibung dieses Ereignisses über das Hilfe-Widget rechts oben. Vielen Dank für deine Hilfe!
|
||||
@ -7,36 +7,52 @@ BtnRegister: Anmelden
|
||||
BtnDeregister: Abmelden
|
||||
BtnHijack: Sitzung übernehmen
|
||||
BtnSave: Speichern
|
||||
PressSaveToSave: Änderungen werden erst durch Drücken des Knopfes "Speichern" gespeichert.
|
||||
BtnHandIn: Abgeben
|
||||
BtnCandidatesInfer: Studienfachzuordnung automatisch lernen
|
||||
BtnCandidatesDeleteConflicts: Konflikte löschen
|
||||
BtnCandidatesDeleteAll: Alle Beobachtungen löschen
|
||||
BtnResetTokens: Authorisierungs-Tokens invalidieren
|
||||
BtnLecInvAccept: Annehmen
|
||||
BtnLecInvDecline: Ablehnen
|
||||
BtnCorrInvAccept: Annehmen
|
||||
BtnCorrInvDecline: Ablehnen
|
||||
BtnSubmissionsAssign: Abgaben automatisch zuteilen
|
||||
|
||||
|
||||
Aborted: Abgebrochen
|
||||
Remarks: Hinweise
|
||||
Registered: Angemeldet
|
||||
RegisteredHeader: Anmeldung
|
||||
RegisteredSince date@Text: Angemeldet seit #{date}
|
||||
RegisteredSince: Angemeldet seit
|
||||
RegisterFrom: Anmeldungen von
|
||||
RegisterTo: Anmeldungen bis
|
||||
DeRegUntil: Abmeldungen bis
|
||||
RegisterRetry: Sie wurden noch nicht angemeldet. Drücken Sie dazu den Knopf "Anmelden"
|
||||
|
||||
GenericKey: Schlüssel
|
||||
GenericShort: Kürzel
|
||||
GenericIsNew: Neu
|
||||
GenericHasConflict: Konflikt
|
||||
GenericBack: Zurück
|
||||
GenericChange: Änderung
|
||||
GenericNumChange: +/-
|
||||
GenericMin: Min
|
||||
GenericAvg: Avg
|
||||
GenericMax: Max
|
||||
GenericAll: Insgesamt
|
||||
|
||||
SummerTerm year@Integer: Sommersemester #{display year}
|
||||
WinterTerm year@Integer: Wintersemester #{display year}/#{display $ succ year}
|
||||
SummerTermShort year@Integer: SoSe #{display year}
|
||||
WinterTermShort year@Integer: WiSe #{display year}/#{display $ mod (succ year) 100}
|
||||
SummerTerm year@Integer: Sommersemester #{year}
|
||||
WinterTerm year@Integer: Wintersemester #{year}/#{succ year}
|
||||
SummerTermShort year@Integer: SoSe #{year}
|
||||
WinterTermShort year@Integer: WiSe #{year}/#{mod (succ year) 100}
|
||||
PSLimitNonPositive: “pagesize” muss größer als null sein
|
||||
Page num@Int64: #{display num}
|
||||
Page num@Int64: #{num}
|
||||
|
||||
TermsHeading: Semesterübersicht
|
||||
TermCurrent: Aktuelles Semester
|
||||
TermEditHeading: Semester editieren/anlegen
|
||||
TermEditTid tid@TermId: Semester #{display tid} editieren
|
||||
TermEdited tid@TermId: Semester #{display tid} erfolgreich editiert.
|
||||
TermEditTid tid@TermId: Semester #{tid} editieren
|
||||
TermEdited tid@TermId: Semester #{tid} erfolgreich editiert.
|
||||
TermNewTitle: Semester editieren/anlegen.
|
||||
InvalidInput: Eingaben bitte korrigieren.
|
||||
Term: Semester
|
||||
@ -55,7 +71,7 @@ TermActive: Aktiv
|
||||
|
||||
|
||||
SchoolListHeading: Übersicht über verwaltete Institute
|
||||
SchoolHeading school@SchoolName: Übersicht #{display school}
|
||||
SchoolHeading school@SchoolName: Übersicht #{school}
|
||||
|
||||
LectureStart: Beginn Vorlesungen
|
||||
|
||||
@ -64,31 +80,34 @@ CourseShort: Kürzel
|
||||
CourseCapacity: Kapazität
|
||||
CourseCapacityTip: Anzahl erlaubter Kursanmeldungen, leer lassen für unbeschränkte Kurskapazität
|
||||
CourseNoCapacity: In diesem Kurs sind keine Plätze mehr frei.
|
||||
TutorialNoCapacity: In dieser Übung sind keine Plätze mehr frei.
|
||||
CourseNotEmpty: In diesem Kurs sind momentan Teilnehmer angemeldet.
|
||||
CourseRegisterOk: Anmeldung erfolgreich
|
||||
CourseDeregisterOk: Erfolgreich abgemeldet
|
||||
CourseDeregisterLecturerTip: Wenn Sie den Teilnehmer vom Kurs abmelden kann es sein, dass sie Zugriff auf diese Daten verlieren
|
||||
CourseStudyFeature: Assoziiertes Hauptfach
|
||||
CourseStudyFeatureUpdated: Assoziiertes Hauptfach geändert
|
||||
CourseTutorial: Tutorium
|
||||
CourseStudyFeatureTooltip: Korrekte Angabe kann Notenweiterleitungen beschleunigen
|
||||
CourseSecretWrong: Falsches Kennwort
|
||||
CourseSecret: Zugangspasswort
|
||||
CourseNewOk tid@TermId ssh@SchoolId csh@CourseShorthand: Kurs #{display tid}-#{display ssh}-#{csh} wurde erfolgreich erstellt.
|
||||
CourseEditOk tid@TermId ssh@SchoolId csh@CourseShorthand: Kurs #{display tid}-#{display ssh}-#{csh} wurde erfolgreich geändert.
|
||||
CourseNewDupShort tid@TermId ssh@SchoolId csh@CourseShorthand: Kurs #{display tid}-#{display ssh}-#{csh} konnte nicht erstellt werden: Es gibt bereits einen anderen Kurs mit dem Kürzel #{csh} in diesem Semester.
|
||||
CourseEditDupShort tid@TermId ssh@SchoolId csh@CourseShorthand: Kurs #{display tid}-#{display ssh}-#{csh} konnte nicht geändert werden: Es gibt bereits einen anderen Kurs mit dem Kürzel #{csh} in diesem Semester.
|
||||
CourseEditOk tid@TermId ssh@SchoolId csh@CourseShorthand: Kurs #{tid}-#{ssh}-#{csh} wurde erfolgreich geändert.
|
||||
CourseNewDupShort tid@TermId ssh@SchoolId csh@CourseShorthand: Kurs #{tid}-#{ssh}-#{csh} konnte nicht erstellt werden: Es gibt bereits einen anderen Kurs mit dem Kürzel #{csh} in diesem Semester.
|
||||
CourseEditDupShort tid@TermId ssh@SchoolId csh@CourseShorthand: Kurs #{tid}-#{ssh}-#{csh} konnte nicht geändert werden: Es gibt bereits einen anderen Kurs mit dem Kürzel #{csh} in diesem Semester.
|
||||
FFSheetName: Name
|
||||
TermCourseListHeading tid@TermId: Kursübersicht #{display tid}
|
||||
TermSchoolCourseListHeading tid@TermId school@SchoolName: Kursübersicht #{display tid} für #{display school}
|
||||
TermCourseListHeading tid@TermId: Kursübersicht #{tid}
|
||||
TermSchoolCourseListHeading tid@TermId school@SchoolName: Kursübersicht #{tid} für #{school}
|
||||
CourseListTitle: Alle Kurse
|
||||
TermCourseListTitle tid@TermId: Kurse #{display tid}
|
||||
TermSchoolCourseListTitle tid@TermId school@SchoolName: Kurse #{display tid} für #{display school}
|
||||
TermCourseListTitle tid@TermId: Kurse #{tid}
|
||||
TermSchoolCourseListTitle tid@TermId school@SchoolName: Kurse #{tid} für #{school}
|
||||
CourseNewHeading: Neuen Kurs anlegen
|
||||
CourseEditHeading tid@TermId ssh@SchoolId csh@CourseShorthand: Kurs #{display tid}-#{display ssh}-#{csh} editieren
|
||||
CourseEditHeading tid@TermId ssh@SchoolId csh@CourseShorthand: Kurs #{tid}-#{ssh}-#{csh} editieren
|
||||
CourseEditTitle: Kurs editieren/anlegen
|
||||
CourseMembers: Teilnehmer
|
||||
CourseMemberOf: Teilnehmer
|
||||
CourseMembersCount n@Int: #{display n}
|
||||
CourseMembersCountLimited n@Int max@Int: #{display n}/#{display max}
|
||||
CourseMembersCountOf n@Int mbNum@IntMaybe: #{display n} Anmeldungen #{maybeDisplay " von " mbNum " möglichen"}
|
||||
CourseMembersCount n@Int: #{n}
|
||||
CourseMembersCountLimited n@Int max@Int: #{n}/#{max}
|
||||
CourseMembersCountOf n@Int mbNum@IntMaybe: #{n} Anmeldungen #{maybeToMessage " von " mbNum " möglichen"}
|
||||
CourseName: Name
|
||||
CourseDescription: Beschreibung
|
||||
CourseDescriptionTip: Beliebiges HTML-Markup ist gestattet
|
||||
@ -114,6 +133,10 @@ CourseUserNoteSaved: Notizänderungen gespeichert
|
||||
CourseUserNoteDeleted: Teilnehmernotiz gelöscht
|
||||
CourseUserDeregister: Abmelden
|
||||
CourseUsersDeregistered count@Int64: #{show count} Teilnehmer abgemeldet
|
||||
CourseUserSendMail: Mitteilung verschicken
|
||||
TutorialUserDeregister: Vom Tutorium Abmelden
|
||||
TutorialUserSendMail: Mitteilung verschicken
|
||||
TutorialUsersDeregistered count@Int64: #{show count} Tutorium-Teilnehmer abgemeldet
|
||||
|
||||
CourseLecturers: Kursverwalter
|
||||
CourseLecturer: Dozent
|
||||
@ -122,25 +145,25 @@ CourseLecturerAlreadyAdded email@UserEmail: Es gibt bereits einen Kursverwalter
|
||||
CourseRegistrationEndMustBeAfterStart: Ende des Anmeldezeitraums muss nach dem Anfang liegen
|
||||
CourseDeregistrationEndMustBeAfterStart: Ende des Abmeldezeitraums muss nach dem Anfang des Anmeldezeitraums liegen
|
||||
CourseUserMustBeLecturer: Aktueller Benutzer muss als Kursverwalter eingetragen sein
|
||||
CourseLecturerRightsIdentical: Alle Sorten von Kursverwalter haben identische Rechte
|
||||
CourseLecturerRightsIdentical: Alle Sorten von Kursverwalter haben identische Rechte.
|
||||
|
||||
NoSuchTerm tid@TermId: Semester #{display tid} gibt es nicht.
|
||||
NoSuchSchool ssh@SchoolId: Institut #{display ssh} gibt es nicht.
|
||||
NoSuchCourseShorthand csh@CourseShorthand: Kein Kurs mit Kürzel #{display csh} bekannt.
|
||||
NoSuchTerm tid@TermId: Semester #{tid} gibt es nicht.
|
||||
NoSuchSchool ssh@SchoolId: Institut #{ssh} gibt es nicht.
|
||||
NoSuchCourseShorthand csh@CourseShorthand: Kein Kurs mit Kürzel #{csh} bekannt.
|
||||
NoSuchCourse: Keinen passenden Kurs gefunden.
|
||||
|
||||
Sheet: Blatt
|
||||
SheetList tid@TermId ssh@SchoolId csh@CourseShorthand: #{display tid}-#{display ssh}-#{csh} Übersicht Übungsblätter
|
||||
SheetNewHeading tid@TermId ssh@SchoolId csh@CourseShorthand: #{display tid}-#{display ssh}-#{csh} Neues Übungsblatt anlegen
|
||||
SheetNewOk tid@TermId ssh@SchoolId csh@CourseShorthand sheetName@SheetName: #{sheetName} wurde als neues Übungsblatt im Kurs #{display tid}-#{display ssh}-#{csh} erfolgreich erstellt.
|
||||
SheetTitle tid@TermId ssh@SchoolId csh@CourseShorthand sheetName@SheetName: #{display tid}-#{display ssh}-#{csh} #{sheetName}
|
||||
SheetTitleNew tid@TermId ssh@SchoolId csh@CourseShorthand : #{display tid}-#{display ssh}-#{csh}: Neues Übungsblatt
|
||||
SheetEditHead tid@TermId ssh@SchoolId csh@CourseShorthand sheetName@SheetName: #{display tid}-#{display ssh}-#{csh} #{sheetName} editieren
|
||||
SheetEditOk tid@TermId ssh@SchoolId csh@CourseShorthand sheetName@SheetName: Übungsblatt #{sheetName} aus Kurs #{display tid}-#{display ssh}-#{csh} wurde gespeichert.
|
||||
SheetNameDup tid@TermId ssh@SchoolId csh@CourseShorthand sheetName@SheetName: Es gibt bereits ein Übungsblatt #{sheetName} in diesem Kurs #{display tid}-#{display ssh}-#{csh}.
|
||||
SheetDelHead tid@TermId ssh@SchoolId csh@CourseShorthand sheetName@SheetName: #{sheetName} wirklich aus Kurs #{display tid}-#{display ssh}-#{csh} herauslöschen? Alle assoziierten Abgaben und Korrekturen gehen ebenfalls verloren!
|
||||
SheetDelOk tid@TermId ssh@SchoolId csh@CourseShorthand sheetName@SheetName: #{display tid}-#{display ssh}-#{csh}: #{sheetName} gelöscht.
|
||||
SheetDelHasSubmissions objs@Int: Inkl. #{tshow objs} #{pluralDE objs "Abgabe" "Abgaben"}!
|
||||
SheetList tid@TermId ssh@SchoolId csh@CourseShorthand: #{tid}-#{ssh}-#{csh} Übersicht Übungsblätter
|
||||
SheetNewHeading tid@TermId ssh@SchoolId csh@CourseShorthand: #{tid}-#{ssh}-#{csh} Neues Übungsblatt anlegen
|
||||
SheetNewOk tid@TermId ssh@SchoolId csh@CourseShorthand sheetName@SheetName: #{sheetName} wurde als neues Übungsblatt im Kurs #{tid}-#{ssh}-#{csh} erfolgreich erstellt.
|
||||
SheetTitle tid@TermId ssh@SchoolId csh@CourseShorthand sheetName@SheetName: #{tid}-#{ssh}-#{csh} #{sheetName}
|
||||
SheetTitleNew tid@TermId ssh@SchoolId csh@CourseShorthand : #{tid}-#{ssh}-#{csh}: Neues Übungsblatt
|
||||
SheetEditHead tid@TermId ssh@SchoolId csh@CourseShorthand sheetName@SheetName: #{tid}-#{ssh}-#{csh} #{sheetName} editieren
|
||||
SheetEditOk tid@TermId ssh@SchoolId csh@CourseShorthand sheetName@SheetName: Übungsblatt #{sheetName} wurde gespeichert in Kurs #{tid}-#{ssh}-#{csh}
|
||||
SheetNameDup tid@TermId ssh@SchoolId csh@CourseShorthand sheetName@SheetName: Es gibt bereits ein Übungsblatt #{sheetName} in diesem Kurs #{tid}-#{ssh}-#{csh}
|
||||
SheetDelHead tid@TermId ssh@SchoolId csh@CourseShorthand sheetName@SheetName: #{sheetName} wirklich aus Kurs #{tid}-#{ssh}-#{csh} herauslöschen? Alle assoziierten Abgaben und Korrekturen gehen ebenfalls verloren!
|
||||
SheetDelOk tid@TermId ssh@SchoolId csh@CourseShorthand sheetName@SheetName: #{tid}-#{ssh}-#{csh}: #{sheetName} gelöscht.
|
||||
SheetDelHasSubmissions objs@Int: Inkl. #{objs} #{pluralDE objs "Abgabe" "Abgaben"}!
|
||||
|
||||
SheetDeleteQuestion: Wollen Sie das unten aufgeführte Übungsblatt und alle zugehörigen Abgaben wirklich löschen?
|
||||
SheetDeleted: Übungsblatt gelöscht
|
||||
@ -153,14 +176,14 @@ SheetHintFrom: Hinweis ab
|
||||
SheetSolution: Lösung
|
||||
SheetSolutionFrom: Lösung ab
|
||||
SheetMarking: Hinweise für Korrektoren
|
||||
SheetMarkingFiles: Korrektur
|
||||
SheetType: Wertung
|
||||
SheetInvisible: Dieses Übungsblatt ist für Teilnehmer momentan unsichtbar!
|
||||
SheetInvisibleUntil date@Text: Dieses Übungsblatt ist für Teilnehmer momentan unsichtbar bis #{date}!
|
||||
SheetName: Name
|
||||
SheetDescription: Hinweise für Teilnehmer
|
||||
SheetGroup: Gruppenabgabe
|
||||
SheetVisibleFrom: Sichtbar für Teilnehmer ab
|
||||
SheetVisibleFromTip: Ohne Datum nie sichtbar und keine Abgabe möglich; nur für unfertige Blätter leer lassen, deren Fristen/Bewertung sich noch ändern kann
|
||||
SheetVisibleFromTip: Ohne Datum nie sichtbar und keine Abgabe möglich; nur für unfertige Blätter leer lassen, deren Bewertung/Fristen sich noch ändern können
|
||||
SheetActiveFrom: Beginn Abgabezeitraum
|
||||
SheetActiveFromTip: Download der Aufgabenstellung erst ab diesem Datum möglich
|
||||
SheetActiveTo: Ende Abgabezeitraum
|
||||
@ -170,27 +193,37 @@ SheetMarkingTip: Hinweise zur Korrektur, sichtbar nur für Korrektoren
|
||||
SheetPseudonym: Persönliches Abgabe-Pseudonym
|
||||
SheetGeneratePseudonym: Generieren
|
||||
|
||||
SheetFormType: Wertung & Abgabe
|
||||
SheetFormTimes: Zeiten
|
||||
SheetFormFiles: Dateien
|
||||
|
||||
SheetErrVisibility: "Beginn Abgabezeitraum" muss nach "Sichbar für Teilnehmer ab" liegen
|
||||
SheetErrDeadlineEarly: "Ende Abgabezeitraum" muss nach "Beginn Abzeitraum" liegen
|
||||
SheetErrHintEarly: Hinweise dürfen erst nach Beginn des Abgabezeitraums herausgegeben werden
|
||||
SheetErrSolutionEarly: Lösungen dürfen erst nach Ende der Abgabezeitraums herausgegeben werden
|
||||
|
||||
SheetNoCurrent: Es gibt momentan kein aktives Übungsblatt.
|
||||
SheetNoOldUnassigned: Alle Abgaben inaktiver Blätter sind bereits einen Korrektor zugeteilt.
|
||||
SheetsUnassignable name@Text: Momentan keine Abgaben zuteilbar für #{name}
|
||||
|
||||
Deadline: Abgabe
|
||||
Done: Eingereicht
|
||||
|
||||
Submission: Abgabenummer
|
||||
SubmissionsCourse tid@TermId ssh@SchoolId csh@CourseShorthand: Alle Abgaben Kurs #{display tid}-#{display ssh}-#{csh}
|
||||
SubmissionsCourse tid@TermId ssh@SchoolId csh@CourseShorthand: Alle Abgaben Kurs #{tid}-#{ssh}-#{csh}
|
||||
SubmissionsSheet sheetName@SheetName: Abgaben für #{sheetName}
|
||||
SubmissionWrongSheet: Abgabenummer gehört nicht zum angegebenen Übungsblatt.
|
||||
SubmissionAlreadyExists: Sie haben bereits eine Abgabe zu diesem Übungsblatt.
|
||||
SubmissionEditHead tid@TermId ssh@SchoolId csh@CourseShorthand sheetName@SheetName: #{display tid}-#{display ssh}-#{csh} #{sheetName}: Abgabe editieren/anlegen
|
||||
CorrectionHead tid@TermId ssh@SchoolId csh@CourseShorthand sheetName@SheetName cid@CryptoFileNameSubmission: #{display tid}-#{display ssh}-#{csh} #{sheetName}: Korrektur
|
||||
SubmissionMember n@Int: Mitabgebende(r) ##{display n}
|
||||
SubmissionEditHead tid@TermId ssh@SchoolId csh@CourseShorthand sheetName@SheetName: #{tid}-#{ssh}-#{csh} #{sheetName}: Abgabe editieren/anlegen
|
||||
CorrectionHead tid@TermId ssh@SchoolId csh@CourseShorthand sheetName@SheetName cid@CryptoFileNameSubmission: #{tid}-#{ssh}-#{csh} #{sheetName}: Korrektur
|
||||
SubmissionMembers: Abgebende
|
||||
SubmissionMember: Abgebende(r)
|
||||
SubmissionArchive: Zip-Archiv der Abgabedatei(en)
|
||||
SubmissionFile: Datei zur Abgabe
|
||||
SubmissionFiles: Abgegebene Dateien
|
||||
SubmissionAlreadyExistsFor email@UserEmail: #{email} hat bereits eine Abgabe zu diesem bÜbungsblatt.
|
||||
SubmissionAlreadyExistsFor email@UserEmail: #{email} hat bereits eine Abgabe zu diesem Übungsblatt.
|
||||
SubmissionUsersEmpty: Es kann keine Abgabe ohne Abgebende erstellt werden
|
||||
SubmissionUserAlreadyAdded: Dieser Nutzer ist bereits als Mitabgebende(r) eingetragen
|
||||
NoOpenSubmissions: Keine unkorrigierten Abgaben vorhanden
|
||||
|
||||
SubmissionsDeleteQuestion n@Int: Wollen Sie #{pluralDE n "die unten aufgeführte Abgabe" "die unten aufgeführten Abgaben"} wirklich löschen?
|
||||
SubmissionsDeleted n@Int: #{pluralDE n "Abgabe gelöscht" "Abgaben gelöscht"}
|
||||
@ -202,10 +235,42 @@ CourseCorrectionsTitle: Korrekturen für diesen Kurs
|
||||
CorrectorsHead sheetName@SheetName: Korrektoren für #{sheetName}
|
||||
CorrectorAssignTitle: Korrektor zuweisen
|
||||
|
||||
MaterialName: Name
|
||||
MaterialType: Art
|
||||
MaterialTypePlaceholder: Folien, Code, Beispiel, ...
|
||||
MaterialTypeSlides: Folien
|
||||
MaterialTypeCode: Code
|
||||
MaterialTypeExample: Beispiel
|
||||
MaterialDescription: Beschreibung
|
||||
MaterialVisibleFrom: Sichtbar für Teilnehmer ab
|
||||
MaterialVisibleFromTip: Ohne Datum nie sichtbar für Teilnehmer; leer lassen ist nur sinnvoll für unfertige Materialien oder zur ausschließlichen Verteilung an Korrektoren
|
||||
MaterialVisibleFromEditWarning: Das Datum der Veröffentlichung liegt in der Vergangenheit und sollte nicht mehr verändert werden, da dies die Benutzer verwirren könnte.
|
||||
MaterialInvisible: Dieses Material ist für Teilnehmer momentan unsichtbar!
|
||||
MaterialFiles: Dateien
|
||||
MaterialHeading materialName@MaterialName: Material "#{materialName}"
|
||||
MaterialListHeading: Materialien
|
||||
MaterialNewHeading: Neues Material veröffentlichen
|
||||
MaterialNewTitle: Neues Material
|
||||
MaterialEditHeading materialName@MaterialName: Material "#{materialName}" editieren
|
||||
MaterialEditTitle materialName@MaterialName: Material "#{materialName}" editieren
|
||||
MaterialSaveOk tid@TermId ssh@SchoolId csh@CourseShorthand materialName@MaterialName: Material "#{materialName}" erfolgreich gespeichert in Kurs #{tid}-#{ssh}-#{csh}
|
||||
MaterialNameDup tid@TermId ssh@SchoolId csh@CourseShorthand materialName@MaterialName: Es gibt bereits Material mit Namen "#{materialName}" in diesem Kurs #{tid}-#{ssh}-#{csh}
|
||||
MaterialDeleteCaption: Wollen Sie das unten aufgeführte Material wirklich löschen?
|
||||
MaterialDelHasFiles count@Int64: inklusive #{count} #{pluralDE count "Datei" "Dateien"}
|
||||
MaterialIsVisible: Achtung, dieses Material wurde bereits veröffentlicht.
|
||||
MaterialDeleted materialName@MaterialName: Material "#{materialName}" gelöscht
|
||||
|
||||
|
||||
Unauthorized: Sie haben hierfür keine explizite Berechtigung.
|
||||
UnauthorizedAnd l@Text r@Text: (#{l} UND #{r})
|
||||
UnauthorizedOr l@Text r@Text: (#{l} ODER #{r})
|
||||
UnauthorizedNoToken: Ihrer Anfrage war kein Authorisierungs-Token beigefügt.
|
||||
UnauthorizedTokenExpired: Ihr Authorisierungs-Token ist abgelaufen.
|
||||
UnauthorizedTokenNotStarted: Ihr Authorisierungs-Token ist noch nicht gültig.
|
||||
UnauthorizedTokenInvalid: Ihr Authorisierungs-Token konnte nicht verarbeitet werden.
|
||||
UnauthorizedTokenInvalidRoute: Ihr Authorisierungs-Token ist auf dieser Unterseite nicht gültig.
|
||||
UnauthorizedTokenInvalidAuthority: Ihr Authorisierungs-Token basiert auf den Rechten eines Nutzers, der nicht mehr existiert.
|
||||
UnauthorizedToken404: Authorisierungs-Tokens können nicht auf Fehlerseiten ausgewertet werden.
|
||||
UnauthorizedSiteAdmin: Sie sind kein System-weiter Administrator.
|
||||
UnauthorizedSchoolAdmin: Sie sind nicht als Administrator für dieses Institut eingetragen.
|
||||
UnauthorizedAdminEscalation: Sie sind nicht Administrator für alle Institute, für die dieser Nutzer Administrator oder Veranstalter ist.
|
||||
@ -218,6 +283,9 @@ UnauthorizedRegistered: Sie sind nicht als Teilnehmer für diese Veranstaltung r
|
||||
UnauthorizedParticipant: Angegebener Benutzer ist nicht als Teilnehmer dieser Veranstaltung registriert.
|
||||
UnauthorizedCourseTime: Dieses Kurs erlaubt momentan keine Anmeldungen.
|
||||
UnauthorizedSheetTime: Dieses Übungsblatt ist momentan nicht freigegeben.
|
||||
UnauthorizedMaterialTime: Dieses Material ist momentan nicht freigegeben.
|
||||
UnauthorizedTutorialTime: Dieses Tutorium erlaubt momentan keine Anmeldungen.
|
||||
UnauthorizedExamTime: Diese Klausur ist momentan nicht freigegeben.
|
||||
UnauthorizedSubmissionOwner: Sie sind an dieser Abgabe nicht beteiligt.
|
||||
UnauthorizedSubmissionRated: Diese Abgabe ist noch nicht korrigiert.
|
||||
UnauthorizedSubmissionCorrector: Sie sind nicht der Korrektor für diese Abgabe.
|
||||
@ -234,28 +302,35 @@ UnsupportedAuthPredicate authTagT@Text shownRoute@String: "#{authTagT}" wurde au
|
||||
UnauthorizedDisabledTag authTag@AuthTag: Authorisierungsprädikat "#{toPathPiece authTag}" ist für Ihre Sitzung nicht aktiv
|
||||
UnknownAuthPredicate tag@String: Authorisierungsprädikat "#{tag}" ist dem System nicht bekannt
|
||||
UnauthorizedRedirect: Die angeforderte Seite existiert nicht oder Sie haben keine Berechtigung, die angeforderte Seite zu sehen.
|
||||
UnauthorizedSelf: Aktueller Nutzer ist nicht angegebener Benutzer.
|
||||
UnauthorizedTutorialTutor: Sie sind nicht Tutor für dieses Tutorium.
|
||||
UnauthorizedCourseTutor: Sie sind nicht Tutor für diesen Kurs.
|
||||
UnauthorizedTutor: Sie sind nicht Tutor.
|
||||
UnauthorizedTutorialRegisterGroup: Sie sind bereits in einem Tutorium mit derselben Registrierungs-Gruppe.
|
||||
|
||||
EMail: E-Mail
|
||||
EMailUnknown email@UserEmail: E-Mail #{email} gehört zu keinem bekannten Benutzer.
|
||||
NotAParticipant email@UserEmail tid@TermId csh@CourseShorthand: #{email} ist nicht im Kurs #{display tid}-#{csh} angemeldet.
|
||||
NotAParticipant email@UserEmail tid@TermId csh@CourseShorthand: #{email} ist nicht im Kurs #{tid}-#{csh} angemeldet.
|
||||
TooManyParticipants: Es wurden zu viele Mitabgebende angegeben
|
||||
|
||||
AddCorrector: Zusätzlicher Korrektor
|
||||
CorrectorExists email@UserEmail: #{email} ist bereits als Korrektor eingetragen
|
||||
SheetCorrectorsTitle tid@TermId ssh@SchoolId csh@CourseShorthand sheetName@SheetName: Korrektoren für #{display tid}-#{display ssh}-#{csh} #{sheetName}
|
||||
CorrectorExists: Nutzer ist bereits als Korrektor eingetragen
|
||||
SheetCorrectorsTitle tid@TermId ssh@SchoolId csh@CourseShorthand sheetName@SheetName: Korrektoren für #{tid}-#{ssh}-#{csh} #{sheetName}
|
||||
CountTutProp: Tutorien zählen gegen Proportion
|
||||
CountTutPropTip: Wenn Abgaben nach Tutorium zugeteilt werden, zählen diese Zuteilungen in Bezug auf den jeweiligen Anteil?
|
||||
AutoAssignCorrs: Korrekturen nach Ablauf des Abgabezeitraums automatisch zuteilen
|
||||
Corrector: Korrektor
|
||||
Correctors: Korrektoren
|
||||
CorState: Status
|
||||
CorByTut: Nach Tutorium
|
||||
CorByTut: Zuteilung nach Tutorium
|
||||
CorProportion: Anteil
|
||||
CorByProportionOnly proportion@Rational: #{display proportion} Anteile
|
||||
CorByProportionIncludingTutorial proportion@Rational: #{display proportion} Anteile - Tutorium
|
||||
CorByProportionExcludingTutorial proportion@Rational: #{display proportion} Anteile + Tutorium
|
||||
CorDeficitProportion: Defizit Anteile
|
||||
CorByProportionOnly proportion@Rational: #{rationalToFixed3 proportion} Anteile
|
||||
CorByProportionIncludingTutorial proportion@Rational: #{rationalToFixed3 proportion} Anteile - Tutorium
|
||||
CorByProportionExcludingTutorial proportion@Rational: #{rationalToFixed3 proportion} Anteile + Tutorium
|
||||
|
||||
RowCount count@Int64: #{display count} #{pluralDE count "Eintrag" "Einträge"} insgesamt
|
||||
DeleteRow: Zeile entfernen
|
||||
RowCount count@Int64: #{count} #{pluralDE count "passender Eintrag" "passende Einträge"} insgesamt
|
||||
DeleteRow: Entfernen
|
||||
ProportionNegative: Anteile dürfen nicht negativ sein
|
||||
CorrectorUpdated: Korrektor erfolgreich aktualisiert
|
||||
CorrectorsUpdated: Korrektoren erfolgreich aktualisiert
|
||||
@ -275,11 +350,14 @@ ImpressumHeading: Impressum
|
||||
DataProtHeading: Datenschutzerklärung
|
||||
SystemMessageHeading: Uni2work Statusmeldung
|
||||
SystemMessageListHeading: Uni2work Statusmeldungen
|
||||
NotificationSettingsHeading displayName@Text: Benachrichtigungs-Einstellungen für #{displayName}
|
||||
TokensLastReset: Tokens zuletzt invalidiert
|
||||
TokensResetSuccess: Authorisierungs-Tokens invalidiert
|
||||
|
||||
HomeOpenCourses: Kurse mit offener Registrierung
|
||||
HomeUpcomingSheets: Anstehende Übungsblätter
|
||||
|
||||
NumCourses num@Int64: #{display num} Kurse
|
||||
NumCourses num@Int64: #{num} Kurse
|
||||
CloseAlert: Schliessen
|
||||
|
||||
Name: Name
|
||||
@ -291,10 +369,14 @@ Plugin: Plugin
|
||||
Ident: Identifikation
|
||||
LastLogin: Letzter Login
|
||||
Settings: Individuelle Benutzereinstellungen
|
||||
SettingsUpdate: Einstellungen wurden gespeichert.
|
||||
SettingsUpdate: Einstellungen erfolgreich gespeichert
|
||||
NotificationSettingsUpdate: Benachrichtigungs-Einstellungen erfolgreich gespeichert
|
||||
Never: Nie
|
||||
|
||||
PreviouslyUploadedInfo: Bereits hochgeladene Dateien:
|
||||
PreviouslyUploadedDeletionInfo: (Nicht ausgewählte Dateien werden gelöscht)
|
||||
MultiFileUploadInfo: (Mehrere Dateien mit Shift oder Strg auswählen)
|
||||
AddMoreFiles: Weitere Dateien hinzufügen:
|
||||
|
||||
NrColumn: Nr
|
||||
SelectColumn: Auswahl
|
||||
@ -305,32 +387,54 @@ CorrDownload: Herunterladen
|
||||
CorrUploadField: Korrekturen
|
||||
CorrUpload: Korrekturen hochladen
|
||||
CorrSetCorrector: Korrektor zuweisen
|
||||
CorrSetCorrectorTooltip: Bereits verteilte Abgaben müssen zuerst Korrektor <Nichts> zugewiesen werden, bevor diese neu verteilt werden.
|
||||
CorrAutoSetCorrector: Korrekturen verteilen
|
||||
CorrDelete: Abgaben löschen
|
||||
NatField name@Text: #{name} muss eine natürliche Zahl sein!
|
||||
JSONFieldDecodeFailure aesonFailure@String: Konnte JSON nicht parsen: #{aesonFailure}
|
||||
SecretJSONFieldDecryptFailure: Konnte versteckte vertrauliche Daten nicht entschlüsseln
|
||||
|
||||
SubmissionsAlreadyAssigned num@Int64: #{display num} Abgaben waren bereits einem Korrektor zugeteilt und wurden nicht verändert:
|
||||
SubmissionsAssignUnauthorized num@Int64: #{display num} Abgaben können momentan nicht einem Korrektor zugeteilt werden (z.B. weil die Abgabe noch offen ist):
|
||||
UpdatedAssignedCorrectorSingle num@Int64: #{display num} Abgaben wurden dem neuen Korrektor zugeteilt.
|
||||
SubmissionsAlreadyAssigned num@Int64: #{num} Abgaben waren bereits einem Korrektor zugeteilt und wurden nicht verändert:
|
||||
SubmissionsAssignUnauthorized num@Int64: #{num} Abgaben können momentan nicht einem Korrektor zugeteilt werden (z.B. weil die Abgabe noch offen ist):
|
||||
UpdatedAssignedCorrectorSingle num@Int64: #{num} Abgaben wurden dem neuen Korrektor zugeteilt.
|
||||
NoCorrector: Kein Korrektor
|
||||
RemovedCorrections num@Int64: Korrektur-Daten wurden von #{display num} Abgaben entfernt.
|
||||
UpdatedAssignedCorrectorsAuto num@Int64: #{display num} Abgaben wurden unter den Korrektoren aufgeteilt.
|
||||
CouldNotAssignCorrectorsAuto num@Int64: #{display num} Abgaben konnten nicht automatisch zugewiesen werden:
|
||||
SelfCorrectors num@Int64: #{display num} Abgaben wurden Abgebenden als eigenem Korrektor zugeteilt!
|
||||
RemovedCorrections num@Int64: Korrektur-Daten wurden von #{num} Abgaben entfernt.
|
||||
UpdatedAssignedCorrectorsAuto num@Int64: #{num} Abgaben wurden unter den Korrektoren aufgeteilt.
|
||||
UpdatedSheetCorrectorsAutoAssigned n@Int: #{n} #{pluralDE n "Abgabe wurde einem Korrektor" "Abgaben wurden Korrektoren"} zugteilt.
|
||||
UpdatedSheetCorrectorsAutoFailed n@Int: #{n} #{pluralDE n "Abgabe konnte" "Abgaben konnten"} nicht automatisch zugewiesen werden.
|
||||
CouldNotAssignCorrectorsAuto num@Int64: #{num} Abgaben konnten nicht automatisch zugewiesen werden:
|
||||
SelfCorrectors num@Int64: #{num} Abgaben wurden Abgebenden als eigenem Korrektor zugeteilt!
|
||||
|
||||
|
||||
CorrectionsUploaded num@Int64: #{display num} Korrekturen wurden gespeichert:
|
||||
CorrectionSheets: Übersicht Korrekturen nach Blättern
|
||||
CorrectionCorrectors: Übersicht Korrekturen nach Korrektoren
|
||||
AssignSubmissionExceptionNoCorrectors: Es sind keine Korrektoren eingestellt
|
||||
AssignSubmissionExceptionNoCorrectorsByProportion: Es sind keine Korrektoren mit Anteil ungleich Null eingestellt
|
||||
AssignSubmissionExceptionSubmissionsNotFound n@Int: #{n} Abgaben konnten nicht gefunden werden
|
||||
NrSubmittorsTotal: Abgebende
|
||||
NrSubmissionsTotal: Abgaben
|
||||
NrSubmissionsTotalShort: Abg.
|
||||
NrSubmissionsUnassigned: Ohne Korrektor
|
||||
NoCorrectorAssigned: Ohne Korrektor
|
||||
NrCorrectors: Korrektoren
|
||||
NrSubmissionsNewlyAssigned: Neu zugeteilt
|
||||
NrSubmissionsNotAssigned: Nicht zugeteilt
|
||||
NrSubmissionsNotCorrected: Unkorrigiert
|
||||
NrSubmissionsNotCorrectedShort: Unkg.
|
||||
CorrectionTime: Korrekturdauer
|
||||
AssignSubmissionsRandomWarning: Die Zuteilungsvorschau kann von der tatsächlichen Zuteilung abweichen, wenn mehrere Blätter auf einmal zugeteilt werden, da beim Ausgleich der Kontigente nur bereits zugeteilte Abgaben berücksichtigt werden. Da es ein randomisierte Prozess ist, kann es auch bei einzelnen Blättern gerinfgügige Abweichungen geben.
|
||||
|
||||
CorrectionsUploaded num@Int64: #{num} Korrekturen wurden gespeichert:
|
||||
NoCorrectionsUploaded: In der hochgeladenen Datei wurden keine Korrekturen gefunden.
|
||||
|
||||
RatingBy: Korrigiert von
|
||||
HasCorrector: Korrektor zugeteilt
|
||||
AssignedTime: Zuteilung
|
||||
AchievedBonusPoints: Erreichte Bonuspunkte
|
||||
AchievedNormalPoints: Erreichte Punkte
|
||||
AchievedPassPoints: Erreichte Punkte
|
||||
AchievedOf achieved@Points possible@Points: #{display achieved} von #{display possible}
|
||||
PassAchievedOf points@Points passingPoints@Points maxPoints@Points: #{display points} von #{display maxPoints} (Bestanden ab #{display passingPoints})
|
||||
AchievedOf achieved@Points possible@Points: #{achieved} von #{possible}
|
||||
PassAchievedOf points@Points passingPoints@Points maxPoints@Points: #{points} von #{maxPoints} (Bestanden ab #{passingPoints})
|
||||
PassedResult: Ergebnis
|
||||
Passed: Bestanden
|
||||
NotPassed: Nicht bestanden
|
||||
@ -343,16 +447,21 @@ RatingDone: Bewertung sichtbar
|
||||
RatingPercent: Erreicht
|
||||
RatingFiles: Korrigierte Dateien
|
||||
PointsNotPositive: Punktzahl darf nicht negativ sein
|
||||
PointsTooHigh maxPoints@Points: Punktzahl darf nicht höher als #{tshow maxPoints} sein
|
||||
PointsTooHigh maxPoints@Points: Punktzahl darf nicht höher als #{maxPoints} sein
|
||||
RatingPointsDone: Abgabe zählt als korrigiert, gdw. Punktezahl gesetzt ist
|
||||
ColumnRatingPoints: Punktzahl
|
||||
Pseudonyms: Pseudonyme
|
||||
|
||||
Files: Dateien
|
||||
FileTitle: Dateiname
|
||||
FileModified: Letzte Änderung
|
||||
VisibleFrom: Veröffentlicht
|
||||
AccessibleSince: Verfügbar seit
|
||||
|
||||
|
||||
Corrected: Korrigiert
|
||||
CorrectionAchievedPoints: Erzielte Punkte
|
||||
CorrectionAchievedPass: Bestanden
|
||||
FileCorrected: Korrigiert (Dateien)
|
||||
FileCorrectedDeleted: Korrigiert (gelöscht)
|
||||
RatingUpdated: Korrektur gespeichert
|
||||
@ -368,11 +477,13 @@ RatingNegative: Bewertungspunkte dürfen nicht negativ sein
|
||||
RatingExceedsMax: Bewertung übersteigt die erlaubte Maximalpunktzahl
|
||||
RatingNotExpected: Keine Bewertungen erlaubt
|
||||
RatingBinaryExpected: Bewertung muss 0 (=durchgefallen) oder 1 (=bestanden) sein
|
||||
RatingPointsRequired: Bewertung erfordert für dieses Blatt eine Punktzahl
|
||||
|
||||
SubmissionSinkExceptionDuplicateFileTitle file@FilePath: Dateiname #{show file} kommt mehrfach im Zip-Archiv vor
|
||||
SubmissionSinkExceptionDuplicateRating: Mehr als eine Bewertung gefunden.
|
||||
SubmissionSinkExceptionRatingWithoutUpdate: Bewertung gefunden, es ist hier aber keine Bewertung der Abgabe möglich.
|
||||
SubmissionSinkExceptionForeignRating smid@CryptoFileNameSubmission: Fremde Bewertung für Abgabe #{toPathPiece smid} enthalten. Bewertungen müssen sich immer auf die gleiche Abgabe beziehen!
|
||||
SubmissionSinkExceptionInvalidFileTitleExtension file@FilePath: Dateiname #{show file} hat keine der für dieses Übungsblatt zulässigen Dateiendungen.
|
||||
|
||||
MultiSinkException name@Text error@Text: In Abgabe #{name} ist ein Fehler aufgetreten: #{error}
|
||||
|
||||
@ -387,6 +498,8 @@ LecturerFor: Dozent
|
||||
LecturersFor: Dozenten
|
||||
AssistantFor: Assistent
|
||||
AssistantsFor: Assistenten
|
||||
TutorsFor n@Int: #{pluralDE n "Tutor" "Tutoren"}
|
||||
CorrectorsFor n@Int: #{pluralDE n "Korrektor" "Korrektoren"}
|
||||
ForSchools n@Int: für #{pluralDE n "Institut" "Institute"}
|
||||
UserListTitle: Komprehensive Benutzerliste
|
||||
AccessRightsSaved: Berechtigungsänderungen wurden gespeichert.
|
||||
@ -414,34 +527,52 @@ LastEdit: Letzte Änderung
|
||||
LastEditByUser: Ihre letzte Bearbeitung
|
||||
NoEditByUser: Nicht von Ihnen bearbeitet
|
||||
|
||||
SubmissionFilesIgnored: Es wurden Dateien in der hochgeladenen Abgabe ignoriert:
|
||||
SubmissionFilesIgnored n@Int: Es #{pluralDE n "wurde" "wurden"} #{n} #{pluralDE n "Datei" "Dateien"} in der hochgeladenen Abgabe ignoriert
|
||||
SubmissionDoesNotExist smid@CryptoFileNameSubmission: Es existiert keine Abgabe mit Nummer #{toPathPiece smid}.
|
||||
|
||||
LDAPLoginTitle: Campus-Login
|
||||
PWHashLoginTitle: Uni2work-Login
|
||||
PWHashLoginNote: Dieses Formular ist zu verwenden, wenn Sie vom Uni2work-Team spezielle Logindaten erhalten haben. Normale Nutzer melden sich bitte via Campus-Login an!
|
||||
DummyLoginTitle: Development-Login
|
||||
LoginNecessary: Bitte melden Sie sich dazu vorher an!
|
||||
|
||||
CorrectorNormal: Normal
|
||||
CorrectorMissing: Abwesend
|
||||
CorrectorExcused: Entschuldigt
|
||||
CorrectorStateTip: Abwesende Korrektoren bekommen bei späteren Übungsblättern mehr Korrekturen zum Ausgleich zugewiesen. Entschuldigte Korrektoren müssen nicht nacharbeiten.
|
||||
|
||||
DayIsAHoliday tid@TermId date@Text: #{date} ist ein Feiertag
|
||||
DayIsOutOfLecture tid@TermId date@Text: #{date} ist außerhalb der Vorlesungszeit des #{display tid}
|
||||
DayIsOutOfTerm tid@TermId date@Text: #{date} liegt nicht im #{display tid}
|
||||
DayIsAHoliday tid@TermId name@Text date@Text: "#{name}" (#{date}) ist ein Feiertag
|
||||
DayIsOutOfLecture tid@TermId name@Text date@Text: "#{name}" (#{date}) ist außerhalb der Vorlesungszeit des #{tid}
|
||||
DayIsOutOfTerm tid@TermId name@Text date@Text: "#{name}" (#{date}) liegt nicht im Semester #{tid}
|
||||
|
||||
UploadModeNone: Kein Upload
|
||||
UploadModeUnpack: Upload, einzelne Datei
|
||||
UploadModeNoUnpack: Upload, ZIP-Archive entpacken
|
||||
UploadModeAny: Upload, beliebige Datei(en)
|
||||
UploadModeSpecific: Upload, vorgegebene Dateinamen
|
||||
|
||||
SheetNoSubmissions: Keine Abgabe
|
||||
SheetCorrectorSubmissions: Abgabe extern mit Pseudonym
|
||||
SheetUserSubmissions: Direkte Abgabe
|
||||
UploadModeUnpackZips: Abgabe mehrerer Dateien
|
||||
UploadModeUnpackZipsTip: Wenn die Abgabe mehrerer Dateien erlaubt ist, werden auch unterstützte Archiv-Formate zugelassen. Diese werden nach dann beim Hochladen automatisch entpackt.
|
||||
|
||||
SheetCorrectorSubmissionsTip: Abgabe erfolgt über ein Uni2work-externes Verfahren (zumeist in Papierform durch Einwurf) unter Angabe eines persönlichen Pseudonyms. Korrektorn können mithilfe des Pseudonyms später Korrekturergebnisse in Uni2work eintragen, damit Sie sie einsehen können.
|
||||
UploadModeExtensionRestriction: Zulässige Dateiendungen
|
||||
UploadModeExtensionRestrictionTip: Komma-separiert. Wenn keine Dateiendungen angegeben werden erfolgt keine Einschränkung.
|
||||
UploadModeExtensionRestrictionEmpty: Liste von zulässigen Dateiendungen darf nicht leer sein
|
||||
|
||||
UploadSpecificFiles: Vorgegebene Dateinamen
|
||||
NoUploadSpecificFilesConfigured: Wenn der Abgabemodus vorgegebene Dateinamen vorsieht, muss mindestens ein vorgegebener Dateiname konfiguriert werden.
|
||||
UploadSpecificFilesDuplicateNames: Vorgegebene Dateinamen müssen eindeutig sein
|
||||
UploadSpecificFilesDuplicateLabels: Bezeichner für vorgegebene Dateinamen müssen eindeutig sein
|
||||
UploadSpecificFileLabel: Bezeichnung
|
||||
UploadSpecificFileName: Dateiname
|
||||
UploadSpecificFileRequired: Zur Abgabe erforderlich
|
||||
|
||||
NoSubmissions: Keine Abgabe
|
||||
CorrectorSubmissions: Abgabe extern mit Pseudonym
|
||||
UserSubmissions: Direkte Abgabe
|
||||
BothSubmissions: Abgabe direkt & extern mit Pseudonym
|
||||
|
||||
SheetCorrectorSubmissionsTip: Abgabe erfolgt über ein Uni2work-externes Verfahren (zumeist in Papierform durch Einwurf) unter Angabe eines persönlichen Pseudonyms. Korrektoren können mithilfe des Pseudonyms später Korrekturergebnisse in Uni2work eintragen, damit Sie sie einsehen können.
|
||||
|
||||
SubmissionNoUploadExpected: Es ist keine Abgabe von Dateien vorgesehen.
|
||||
SubmissionReplace: Abgabe ersetzen
|
||||
|
||||
AdminFeaturesHeading: Studiengänge
|
||||
StudyTerms: Studiengänge
|
||||
@ -492,15 +623,15 @@ MailSubjectSheetActive csh@CourseShorthand sheetName@SheetName: #{sheetName} in
|
||||
MailSheetActiveIntro courseName@Text termDesc@Text sheetName@SheetName: Sie können nun #{sheetName} im Kurs #{courseName} (#{termDesc}) herunterladen.
|
||||
|
||||
MailSubjectSubmissionsUnassigned csh@CourseShorthand sheetName@SheetName: Abgaben zu #{sheetName} in #{csh} konnten nicht verteilt werden
|
||||
MailSubmissionsUnassignedIntro n@Int courseName@Text termDesc@Text sheetName@SheetName: #{tshow n} Abgaben zu #{sheetName} im Kurs #{courseName} (#{termDesc}) konnten nicht automatisiert verteilt werden.
|
||||
MailSubmissionsUnassignedIntro n@Int courseName@Text termDesc@Text sheetName@SheetName: #{n} Abgaben zu #{sheetName} im Kurs #{courseName} (#{termDesc}) konnten nicht automatisiert verteilt werden.
|
||||
|
||||
MailSubjectSheetSoonInactive csh@CourseShorthand sheetName@SheetName: #{sheetName} in #{csh} kann nur noch kurze Zeit abgegeben werden
|
||||
MailSheetSoonInactiveIntro courseName@Text termDesc@Text sheetName@SheetName: Abgabefirst für #{sheetName} im Kurs #{courseName} (#{termDesc}) endet in Kürze.
|
||||
MailSubjectSheetInactive csh@CourseShorthand sheetName@SheetName: Abgabezeitraum für #{sheetName} in #{csh} abgelaufen
|
||||
MailSheetInactiveIntro courseName@Text termDesc@Text sheetName@SheetName n@Int num@Int64: Die Abgabefirst für #{sheetName} im Kurs #{courseName} (#{termDesc}) beendet. Es gab #{noneOneMoreDE n "Keine Abgaben" "Nur eine Abgabe von " (display n <> " Abgaben von ")}#{noneOneMoreDE num "" "einem Teilnehmer" (display num <> " Teilnehmern")}.
|
||||
MailSheetInactiveIntro courseName@Text termDesc@Text sheetName@SheetName n@Int num@Int64: Die Abgabefirst für #{sheetName} im Kurs #{courseName} (#{termDesc}) beendet. Es gab #{noneOneMoreDE n "Keine Abgaben" "Nur eine Abgabe von " (toMessage n <> " Abgaben von ")}#{noneOneMoreDE num "" "einem Teilnehmer" (toMessage num <> " Teilnehmern")}.
|
||||
|
||||
MailSubjectCorrectionsAssigned csh@CourseShorthand sheetName@SheetName: Ihnen wurden Korrekturen zu #{sheetName} in #{csh} zugeteilt
|
||||
MailCorrectionsAssignedIntro courseName@Text termDesc@Text sheetName@SheetName n@Int: #{display n} #{pluralDE n "Abgabe wurde" "Abgaben wurden"} Ihnen zur Korrektur für #{sheetName} im Kurs #{courseName} (#{termDesc}) zugeteilt.
|
||||
MailCorrectionsAssignedIntro courseName@Text termDesc@Text sheetName@SheetName n@Int: #{n} #{pluralDE n "Abgabe wurde" "Abgaben wurden"} Ihnen zur Korrektur für #{sheetName} im Kurs #{courseName} (#{termDesc}) zugeteilt.
|
||||
|
||||
MailSubjectUserRightsUpdate name@Text: Berechtigungen für #{name} aktualisiert
|
||||
MailUserRightsIntro name@Text email@UserEmail: #{name} <#{email}> hat folgende Uni2work Berechtigungen:
|
||||
@ -512,9 +643,23 @@ MailEditNotifications: Benachrichtigungen ein-/ausschalten
|
||||
MailSubjectSupport: Supportanfrage
|
||||
MailSubjectSupportCustom customSubject@Text: [Support] #{customSubject}
|
||||
|
||||
CommCourseSubject: Kursmitteilung
|
||||
MailSubjectLecturerInvitation tid@TermId ssh@SchoolId csh@CourseShorthand: [#{tid}-#{ssh}-#{csh}] Einladung zum Kursverwalter
|
||||
InvitationAcceptDecline: Einladung annehmen/ablehnen
|
||||
|
||||
MailSubjectParticipantInvitation tid@TermId ssh@SchoolId csh@CourseShorthand: [#{tid}-#{ssh}-#{csh}] Einladung zum Kursteilname
|
||||
|
||||
MailSubjectCorrectorInvitation tid@TermId ssh@SchoolId csh@CourseShorthand shn@SheetName: [#{tid}-#{ssh}-#{csh}] Einladung zum Korrektor für #{shn}
|
||||
|
||||
MailSubjectTutorInvitation tid@TermId ssh@SchoolId csh@CourseShorthand tutn@TutorialName: [#{tid}-#{ssh}-#{csh}] Einladung zum Tutor für #{tutn}
|
||||
|
||||
MailSubjectExamCorrectorInvitation tid@TermId ssh@SchoolId csh@CourseShorthand examn@ExamName: [#{tid}-#{ssh}-#{csh}] Einladung zum Korrektor für Klausur #{examn}
|
||||
|
||||
MailSubjectSubmissionUserInvitation tid@TermId ssh@SchoolId csh@CourseShorthand shn@SheetName: [#{tid}-#{ssh}-#{csh}] Einladung zu einer Abgabe für #{shn}
|
||||
|
||||
SheetGrading: Bewertung
|
||||
SheetGradingPoints maxPoints@Points: #{tshow maxPoints} Punkte
|
||||
SheetGradingPassPoints maxPoints@Points passingPoints@Points: Bestanden ab #{tshow passingPoints} von #{tshow maxPoints} Punkten
|
||||
SheetGradingPoints maxPoints@Points: #{maxPoints} Punkte
|
||||
SheetGradingPassPoints maxPoints@Points passingPoints@Points: Bestanden ab #{passingPoints} von #{maxPoints} Punkten
|
||||
SheetGradingPassBinary: Bestanden/Nicht Bestanden
|
||||
SheetGradingInfo: "Bestanden nach Punkten" zählt sowohl zur maximal erreichbaren Gesamtpunktzahl also auch zur Anzahl der zu bestehenden Blätter.
|
||||
|
||||
@ -532,8 +677,8 @@ SheetTypeInfoNotGraded: Blätter ohne Wertung werden nirgends angerechnet, die B
|
||||
SheetTypeInfoBonus: Bonus Blätter zählen normal, erhöhen aber nicht die maximal erreichbare Punktzahl bzw. Anzahl zu bestehender Blätter.
|
||||
SheetGradingBonusIncluded: Erzielte Bonuspunkte wurden hier bereits zu den erreichten normalen Punkten hinzugezählt.
|
||||
SummaryTitle: Zusammenfassung über
|
||||
SheetGradingSummaryTitle intgr@Integer: #{display intgr} #{pluralDE intgr "Blatt" "Blätter"}
|
||||
SubmissionGradingSummaryTitle intgr@Integer: #{display intgr} #{pluralDE intgr "Abgabe" "Abgaben"}
|
||||
SheetGradingSummaryTitle intgr@Integer: #{intgr} #{pluralDE intgr "Blatt" "Blätter"}
|
||||
SubmissionGradingSummaryTitle intgr@Integer: #{intgr} #{pluralDE intgr "Abgabe" "Abgaben"}
|
||||
|
||||
SheetTypeBonus': Bonus
|
||||
SheetTypeNormal': Normal
|
||||
@ -549,6 +694,7 @@ SheetGroupNoGroups: Keine Gruppenabgabe
|
||||
SheetGroupMaxGroupsize: Maximale Gruppengröße
|
||||
|
||||
SheetFiles: Übungsblatt-Dateien
|
||||
SheetFileTypeHeader: Zugehörigkeit
|
||||
|
||||
NotificationTriggerSubmissionRatedGraded: Meine Abgabe in einem gewerteten Übungsblatt wurde korrigiert
|
||||
NotificationTriggerSubmissionRated: Meine Abgabe wurde korrigiert
|
||||
@ -660,15 +806,20 @@ MenuInformation: Informationen
|
||||
MenuImpressum: Impressum
|
||||
MenuDataProt: Datenschutz
|
||||
MenuVersion: Versionsgeschichte
|
||||
MenuInstance: Instanz-Identifikation
|
||||
MenuHealth: Instanz-Zustand
|
||||
MenuHelp: Hilfe
|
||||
MenuProfile: Anpassen
|
||||
MenuLogin: Login
|
||||
MenuLogout: Logout
|
||||
MenuCourseList: Kurse
|
||||
MenuCourseMembers: Kursteilnehmer
|
||||
MenuCourseAddMembers: Kursteilnehmer hinzufügen
|
||||
MenuCourseCommunication: Kursmitteilung
|
||||
MenuTermShow: Semester
|
||||
MenuSubmissionDelete: Abgabe löschen
|
||||
MenuUsers: Benutzer
|
||||
MenuUserNotifications: Benachrichtigungs-Einstellungen
|
||||
MenuAdminTest: Admin-Demo
|
||||
MenuMessageList: Systemnachrichten
|
||||
MenuAdminErrMsg: Fehlermeldung entschlüsseln
|
||||
@ -681,6 +832,12 @@ MenuCorrections: Korrekturen
|
||||
MenuCorrectionsOwn: Meine Korrekturen
|
||||
MenuSubmissions: Abgaben
|
||||
MenuSheetList: Übungsblätter
|
||||
MenuMaterialList: Material
|
||||
MenuMaterialNew: Neues Material veröffentlichen
|
||||
MenuMaterialEdit: Material bearbeiten
|
||||
MenuMaterialDelete: Material löschen
|
||||
MenuTutorialList: Tutorien
|
||||
MenuTutorialNew: Neues Tutorium anlegen
|
||||
MenuSheetNew: Neues Übungsblatt anlegen
|
||||
MenuSheetCurrent: Aktuelles Übungsblatt
|
||||
MenuSheetOldUnassigned: Abgaben ohne Korrektor
|
||||
@ -690,27 +847,42 @@ MenuCourseDelete: Kurs löschen
|
||||
MenuSubmissionNew: Abgabe anlegen
|
||||
MenuSubmissionOwn: Abgabe
|
||||
MenuCorrectors: Korrektoren
|
||||
MenuCorrectorsChange: Korrektoren ändern
|
||||
MenuSheetEdit: Übungsblatt editieren
|
||||
MenuSheetDelete: Übungsblatt löschen
|
||||
MenuSheetClone: Als neues Übungsblatt klonen
|
||||
MenuCorrectionsUpload: Korrekturen hochladen
|
||||
MenuCorrectionsDownload: Offene Abgaben herunterladen
|
||||
MenuCorrectionsCreate: Abgaben registrieren
|
||||
MenuCorrectionsGrade: Abgaben bewerten
|
||||
MenuCorrectionsGrade: Abgaben online korrigieren
|
||||
MenuCorrectionsAssign: Zuteilung Korrekturen
|
||||
MenuCorrectionsAssignSheet name@Text: Zuteilung Korrekturen von #{name}
|
||||
MenuAuthPreds: Authorisierungseinstellungen
|
||||
MenuTutorialDelete: Tutorium löschen
|
||||
MenuTutorialEdit: Tutorium editieren
|
||||
MenuTutorialComm: Mitteilung an Teilnehmer
|
||||
MenuExamList: Klausuren
|
||||
MenuExamNew: Neue Klausur anlegen
|
||||
MenuExamEdit: Bearbeiten
|
||||
|
||||
AuthPredsInfo: Um eigene Veranstaltungen aus Sicht der Teilnehmer anzusehen, können Veranstalter und Korrektoren hier die Prüfung ihrer erweiterten Berechtigungen temporär deaktivieren. Abgewählte Prädikate schlagen immer fehl. Abgewählte Prädikate werden also nicht geprüft um Zugriffe zu gewähren, welche andernfalls nicht erlaubt wären. Diese Einstellungen gelten nur temporär bis Ihre Sitzung abgelaufen ist, d.h. bis ihr Browser-Cookie abgelaufen ist. Durch Abwahl von Prädikaten kann man sich höchstens temporär aussperren.
|
||||
AuthPredsActive: Aktive Authorisierungsprädikate
|
||||
AuthPredsActiveChanged: Authorisierungseinstellungen für aktuelle Sitzung gespeichert
|
||||
AuthTagFree: Seite ist universell zugänglich
|
||||
AuthTagAdmin: Nutzer ist Administrator
|
||||
AuthTagToken: Nutzer präsentiert Authorisierungs-Token
|
||||
AuthTagNoEscalation: Nutzer-Rechte werden nicht auf fremde Institute ausgeweitet
|
||||
AuthTagDeprecated: Seite ist nicht überholt
|
||||
AuthTagDevelopment: Seite ist nicht in Entwicklung
|
||||
AuthTagLecturer: Nutzer ist Dozent
|
||||
AuthTagCorrector: Nutzer ist Korrektor
|
||||
AuthTagTutor: Nutzer ist Tutor
|
||||
AuthTagTime: Zeitliche Einschränkungen sind erfüllt
|
||||
AuthTagRegistered: Nutzer ist Kursteilnehmer
|
||||
AuthTagCourseRegistered: Nutzer ist Kursteilnehmer
|
||||
AuthTagTutorialRegistered: Nutzer ist Tutoriumsteilnehmer
|
||||
AuthTagExamRegistered: Nutzer ist Klausurteilnehmer
|
||||
AuthTagParticipant: Nutzer ist mit Kurs assoziiert
|
||||
AuthTagRegisterGroup: Nutzer ist nicht Mitglied eines anderen Tutoriums mit der selben Registrierungs-Gruppe
|
||||
AuthTagCapacity: Kapazität ist ausreichend
|
||||
AuthTagEmpty: Kurs hat keine Teilnehmer
|
||||
AuthTagMaterials: Kursmaterialien sind freigegeben
|
||||
@ -718,6 +890,7 @@ AuthTagOwner: Nutzer ist Besitzer
|
||||
AuthTagRated: Korrektur ist bewertet
|
||||
AuthTagUserSubmissions: Abgaben erfolgen durch Kursteilnehmer
|
||||
AuthTagCorrectorSubmissions: Abgaben erfolgen durch Korrektoren
|
||||
AuthTagSelf: Nutzer greift nur auf eigene Daten zu
|
||||
AuthTagAuthentication: Nutzer ist angemeldet, falls erforderlich
|
||||
AuthTagRead: Zugriff ist nur lesend
|
||||
AuthTagWrite: Zugriff ist i.A. schreibend
|
||||
@ -726,9 +899,269 @@ DeleteCopyStringIfSure n@Int: Wenn Sie sich sicher sind, dass Sie #{pluralDE n "
|
||||
DeleteConfirmation: Bestätigung
|
||||
DeleteConfirmationWrong: Bestätigung muss genau dem angezeigten Text entsprechen.
|
||||
|
||||
DBTIRowsMissing n@Int: #{pluralDE n "Eine Zeile ist" "Einige Zeile sind"} aus der Datenbank verschwunden, seit das Formular für Sie generiert wurde
|
||||
DBTIRowsMissing n@Int: #{pluralDE n "Eine Zeile ist" "Einige Zeilen sind"} aus der Datenbank verschwunden, seit das Formular für Sie generiert wurde
|
||||
|
||||
MassInputAddDimension: Hinzufügen
|
||||
MassInputDeleteCell: Entfernen
|
||||
MassInputAddDimension: +
|
||||
MassInputDeleteCell: -
|
||||
|
||||
NavigationFavourites: Favoriten
|
||||
NavigationFavourites: Favoriten
|
||||
|
||||
CommSubject: Betreff
|
||||
CommBody: Nachricht
|
||||
CommRecipients: Empfänger
|
||||
CommRecipientsTip: Sie selbst erhalten immer eine Kopie der Nachricht
|
||||
CommDuplicateRecipients n@Int: #{n} #{pluralDE n "doppelter" "doppelte"} Empfänger ignoriert
|
||||
CommSuccess n@Int: Nachricht wurde an #{n} Empfänger versandt
|
||||
|
||||
CommCourseHeading: Kursmitteilung
|
||||
CommTutorialHeading: Tutorium-Mitteilung
|
||||
|
||||
RecipientCustom: Weitere Empfänger
|
||||
RecipientToggleAll: Alle/Keine
|
||||
|
||||
RGCourseParticipants: Kursteilnehmer
|
||||
RGCourseLecturers: Kursverwalter
|
||||
RGCourseCorrectors: Korrektoren
|
||||
RGCourseTutors: Tutoren
|
||||
RGTutorialParticipants: Tutorium-Teilnehmer
|
||||
|
||||
MultiSelectFieldTip: Mehrfach-Auswahl ist möglich (Umschalt bzw. Strg)
|
||||
MultiEmailFieldTip: Es sind mehrere, Komma-separierte, E-Mail-Addressen möglich
|
||||
EmailInvitationWarning: Dem System ist kein Nutzer mit dieser Addresse bekannt. Es wird eine Einladung per E-Mail versandt.
|
||||
|
||||
LecturerInvitationAccepted lType@Text csh@CourseShorthand: Sie wurden als #{lType} für #{csh} eingetragen
|
||||
LecturerInvitationDeclined csh@CourseShorthand: Sie haben die Einladung, Kursverwalter für #{csh} zu werden, abgelehnt
|
||||
CourseLecInviteHeading courseName@Text: Einladung zum Kursverwalter für #{courseName}
|
||||
CourseLecInviteExplanation: Sie wurden eingeladen, Verwalter für einen Kurs zu sein.
|
||||
|
||||
CourseParticipantInviteHeading courseName@Text: Einladung zum Kursteilnahmer für #{courseName}
|
||||
CourseParticipantInviteExplanation: Sie wurden eingeladen, an einem Kurs teilzunehmen.
|
||||
CourseParticipantEnlistDirectly: Bekannte Teilnehmer sofort als Teilnehmer eintragen
|
||||
CourseParticipantInviteField: Einzuladende EMail Adressen
|
||||
|
||||
CourseParticipantInvitationAccepted courseName@Text: Sie wurden als Teilnehmer für #{courseName} eingetragen
|
||||
|
||||
|
||||
CorrectorInvitationAccepted shn@SheetName: Sie wurden als Korrektor für #{shn} eingetragen
|
||||
CorrectorInvitationDeclined shn@SheetName: Sie haben die Einladung, Korrektor für #{shn} zu werden, abgelehnt
|
||||
SheetCorrInviteHeading shn@SheetName: Einladung zum Korrektor für #{shn}
|
||||
SheetCorrInviteExplanation: Sie wurden eingeladen, Korrektor für ein Übungsblatt zu sein.
|
||||
|
||||
TutorInvitationAccepted tutn@TutorialName: Sie wurden als Tutor für #{tutn} eingetragen
|
||||
TutorInvitationDeclined tutn@TutorialName: Sie haben die Einladung, Tutor für #{tutn} zu werden, abgelehnt
|
||||
TutorInviteHeading tutn@TutorialName: Einladung zum Tutor für #{tutn}
|
||||
TutorInviteExplanation: Sie wurden eingeladen, Tutor zu sein.
|
||||
|
||||
ExamCorrectorInvitationAccepted examn@ExamName: Sie wurden als Korrektor für Klausur #{examn} eingetragen
|
||||
ExamCorrectorInvitationDeclined examn@ExamName: Sie haben die Einladung, Korrektor für Klausur #{examn} zu werden, abgelehnt
|
||||
ExamCorrectorInviteHeading examn@ExamName: Einladung zum Korrektor für Klausur #{examn}
|
||||
ExamCorrectorInviteExplanation: Sie wurden eingeladen, Klausur-Korrektor zu sein.
|
||||
|
||||
SubmissionUserInvitationAccepted shn@SheetName: Sie wurden als Mitabgebende(r) für eine Abgabe zu #{shn} eingetragen
|
||||
SubmissionUserInvitationDeclined shn@SheetName: Sie haben die Einladung, Mitabgebende(r) für #{shn} zu werden, abgelehnt
|
||||
SubmissionUserInviteHeading shn@SheetName: Einladung zu einer Abgabe für #{shn}
|
||||
SubmissionUserInviteExplanation: Sie wurden eingeladen, Mitabgebende(r) bei einer Abgabe zu sein.
|
||||
|
||||
InvitationAction: Aktion
|
||||
InvitationActionTip: Abgelehnte Einladungen können nicht mehr angenommen werden
|
||||
InvitationMissingRestrictions: Authorisierungs-Token fehlen benötigte Daten
|
||||
InvitationCollision: Einladung konnte nicht angenommen werden da ein derartiger Eintrag bereits existiert
|
||||
InvitationDeclined: Einladung wurde abgelehnt
|
||||
BtnInviteAccept: Einladung annehmen
|
||||
BtnInviteDecline: Einladung ablehnen
|
||||
|
||||
LecturerType: Rolle
|
||||
ScheduleKindWeekly: Wöchentlich
|
||||
|
||||
ScheduleRegular: Planmäßiger Termin
|
||||
ScheduleRegularKind: Plan
|
||||
WeekDay: Wochentag
|
||||
Day: Tag
|
||||
OccurrenceStart: Beginn
|
||||
OccurrenceEnd: Ende
|
||||
ScheduleExists: Dieser Plan existiert bereits
|
||||
|
||||
ScheduleExceptions: Termin-Ausnahmen
|
||||
ScheduleExceptionsTip: Ausfälle überschreiben planmäßiges Stattfinden. Außerplanmäßiges Stattfinden überschreibt Ausfall.
|
||||
ExceptionKind: Termin ...
|
||||
ExceptionKindOccur: Findet statt
|
||||
ExceptionKindNoOccur: Findet nicht statt
|
||||
ExceptionExists: Diese Ausnahme existiert bereits
|
||||
ExceptionNoOccurAt: Termin
|
||||
|
||||
TutorialType: Typ
|
||||
TutorialName: Bezeichnung
|
||||
TutorialParticipants: Teilnehmer
|
||||
TutorialCapacity: Kapazität
|
||||
TutorialFreeCapacity: Freie Plätze
|
||||
TutorialRoom: Regulärer Raum
|
||||
TutorialTime: Zeit
|
||||
TutorialRegistered: Angemeldet
|
||||
TutorialRegGroup: Registrierungs-Gruppe
|
||||
TutorialRegisterFrom: Anmeldungen ab
|
||||
TutorialRegisterTo: Anmeldungen bis
|
||||
TutorialDeregisterUntil: Abmeldungen bis
|
||||
TutorialsHeading: Tutorien
|
||||
TutorialEdit: Bearbeiten
|
||||
TutorialDelete: Löschen
|
||||
|
||||
CourseExams: Klausuren
|
||||
CourseTutorials: Übungen
|
||||
|
||||
ParticipantsN n@Int: #{n} Teilnehmer
|
||||
TutorialDeleteQuestion: Wollen Sie das unten aufgeführte Tutorium wirklich löschen?
|
||||
TutorialDeleted: Tutorium gelöscht
|
||||
|
||||
TutorialRegisteredSuccess tutn@TutorialName: Erfolgreich zum Tutorium #{tutn} angemeldet
|
||||
TutorialDeregisteredSuccess tutn@TutorialName: Erfolgreich vom Tutorium #{tutn} abgemeldet
|
||||
|
||||
TutorialNameTip: Muss eindeutig sein
|
||||
TutorialCapacityNonPositive: Kapazität muss größer oder gleich null sein
|
||||
TutorialCapacityTip: Beschränkt wieviele Studenten sich zu diesem Tutorium anmelden können
|
||||
TutorialRegGroupTip: Studenten können sich in jeweils maximal einem Tutorium pro Registrierungs-Gruppe anmelden. Ist bei zwei oder mehr Tutorien keine Registrierungs-Gruppe gesetzt zählen diese als in verschiedenen Registrierungs-Gruppen
|
||||
TutorialRoomPlaceholder: Raum
|
||||
TutorialTutors: Tutoren
|
||||
TutorialTutorAlreadyAdded: Ein Tutor mit dieser E-Mail ist bereits für dieses Tutorium eingetragen
|
||||
|
||||
TutorialNew: Neues Tutorium
|
||||
|
||||
TutorialNameTaken tutn@TutorialName: Es existiert bereits anderes Tutorium mit Namen #{tutn}
|
||||
TutorialCreated tutn@TutorialName: Tutorium #{tutn} erfolgreich angelegt
|
||||
TutorialEdited tutn@TutorialName: Tutiorium #{tutn} erfolgreich bearbeitet
|
||||
|
||||
TutorialEditHeading tutn@TutorialName: #{tutn} bearbeiten
|
||||
|
||||
MassInputTip: Es können mehrere Werte angegeben werden. Werte müssen mit + zur Liste hinzugefügt werden und können mit - wieder entfernt werden. Alle Änderungen müssen noch durch Drücken des Forumular-Knopfes bestätigt werden.
|
||||
|
||||
HealthReport: Instanz-Zustand
|
||||
InstanceIdentification: Instanz-Identifikation
|
||||
|
||||
InstanceId: Instanz-Nummer
|
||||
ClusterId: Cluster-Nummer
|
||||
|
||||
HealthMatchingClusterConfig: Cluster-geteilte Konfiguration ist aktuell
|
||||
HealthHTTPReachable: Cluster kann an der erwarteten URL über HTTP erreicht werden
|
||||
HealthLDAPAdmins: Anteil der Administratoren, die im LDAP-Verzeichnis gefunden werden können
|
||||
HealthSMTPConnect: SMTP-Server kann erreicht werden
|
||||
HealthWidgetMemcached: Memcached-Server liefert Widgets korrekt aus
|
||||
|
||||
CourseParticipants n@Int: Derzeit #{n} angemeldete Kursteilnehmer
|
||||
CourseParticipantsInvited n@Int: #{n} #{pluralDE n "Einladung" "Einladungen"} per E-Mail verschickt
|
||||
CourseParticipantsAlreadyRegistered n@Int: #{n} Teilnehmer #{pluralDE n "ist" "sind"} bereits angemeldet
|
||||
CourseParticipantsRegisteredWithoutField n@Int: #{n} Teilnehmer #{pluralDE n "wurde ohne assoziiertes Hauptfach" "wurden assoziierte Hauptfächer"} angemeldet, da #{pluralDE n "kein eindeutiges Hauptfach bestimmt werden konnte" "keine eindeutigen Hauptfächer bestimmt werden konnten"}
|
||||
CourseParticipantsRegistered n@Int: #{n} Teilnehmer erfolgreich angemeldet
|
||||
CourseParticipantsRegisterHeading: Kursteilnehmer hinzufügen
|
||||
|
||||
ExamName: Name
|
||||
ExamTime: Termin
|
||||
ExamsHeading: Klausuren
|
||||
ExamNameTip: Muss innerhalb der Veranstaltung eindeutig sein
|
||||
ExamStart: Beginn
|
||||
ExamEnd: Ende
|
||||
ExamDescription: Beschreibung
|
||||
ExamVisibleFrom: Sichtbar ab
|
||||
ExamVisibleFromTip: Ohne Datum nie sichtbar und keine Anmeldung möglich
|
||||
ExamRegisterFrom: Anmeldung ab
|
||||
ExamRegisterFromTip: Zeitpunkt ab dem sich Kursteilnehmer selbständig zur Klausur anmelden können; ohne Datum ist keine Anmeldung möglich
|
||||
ExamRegisterTo: Anmeldung bis
|
||||
ExamDeregisterUntil: Abmeldung bis
|
||||
ExamPublishOccurrenceAssignments: Terminzuteilung den Teilnehmern mitteilen um
|
||||
ExamPublishOccurrenceAssignmentsTip: Ab diesem Zeitpunkt Teilnehmer einsehen zu welchen Teilprüfungen (Räumen) sie angemeldet sind
|
||||
ExamPublishOccurrenceAssignmentsParticipant: Terminzuteilung einsehbar ab
|
||||
ExamFinished: Bewertung abgeschlossen ab
|
||||
ExamFinishedParticipant: Bewertung vorrausichtlich abgeschlossen
|
||||
ExamFinishedTip: Zeitpunkt zu dem Klausurergebnisse den Teilnehmern gemeldet werden
|
||||
ExamClosed: Noten stehen fest ab
|
||||
ExamClosedTip: Zeitpunkt ab dem keine Änderungen an den Ergebnissen zulässig sind; Prüfungsämter bekommen Einsicht
|
||||
ExamShowGrades: Noten anzeigen
|
||||
ExamShowGradesTip: Soll den Teilnehmern ihre genaue Note angezeigt werden, oder sollen sie nur informiert werden, ob sie bestanden haben?
|
||||
ExamPublicStatistics: Statistik veröffentlichen
|
||||
ExamPublicStatisticsTip: Soll die statistische Auswertung auch den Teilnehmer angezeigt werden, sobald diese ihre Noten einsehen können?
|
||||
ExamGradingRule: Notenberechnung
|
||||
ExamGradingManual': Manuell
|
||||
ExamGradingKey': Nach Schlüssel
|
||||
ExamGradingKey: Notenschlüssel
|
||||
ExamGradingKeyTip: Die Grenzen beziehen sich auf die effektive Maximalpunktzahl, nachdem etwaige Bonuspunkte aus dem Übungsbetrieb angerechnet und die Ergebnise der Teilaufgaben mit ihrem Gewicht multipliziert wurden
|
||||
Points: Punkte
|
||||
PointsMustBeNonNegative: Punktegrenzen dürfen nicht negativ sein
|
||||
PointsMustBeMonotonic: Punktegrenzen müssen aufsteigend sein
|
||||
GradingFrom: Ab
|
||||
ExamNew: Neue Klausur
|
||||
ExamBonusRule: Klausurbonus aus Übungsbetrieb
|
||||
ExamNoBonus': Kein Bonus
|
||||
ExamBonusPoints': Umrechnung von Übungspunkten
|
||||
|
||||
ExamEditHeading examn@ExamName: #{examn} bearbeiten
|
||||
|
||||
ExamBonusMaxPoints: Maximal erreichbare Klausur-Bonuspunkte
|
||||
ExamBonusMaxPointsNonPositive: Maximaler Klausurbonus muss positiv und größer null sein
|
||||
ExamBonusOnlyPassed: Bonus nur nach Bestehen anrechnen
|
||||
|
||||
ExamOccurrenceRule: Automatische Terminzuteilung
|
||||
ExamOccurrenceRuleParticipant: Terminzuteilung
|
||||
ExamRoomManual': Keine automatische Zuteilung
|
||||
ExamRoomSurname': Nach Nachname
|
||||
ExamRoomMatriculation': Nach Matrikelnummer
|
||||
ExamRoomRandom': Zufällig pro Teilnehmer
|
||||
|
||||
ExamOccurrences: Prüfungen
|
||||
ExamRoomAlreadyExists: Prüfung ist bereits eingetragen
|
||||
ExamRoom: Raum
|
||||
ExamRoomCapacity: Kapazität
|
||||
ExamRoomCapacityNegative: Kapazität darf nicht negativ sein
|
||||
ExamRoomTime: Termin
|
||||
ExamRoomStart: Beginn
|
||||
ExamRoomEnd: Ende
|
||||
ExamRoomDescription: Beschreibung
|
||||
ExamTimeTip: Nur zur Information der Studierenden, die tatsächliche Zeitangabe erfolgt pro Prüfung
|
||||
ExamRoomRegistered: Zugeteilt
|
||||
|
||||
ExamFormTimes: Zeiten
|
||||
ExamFormOccurrences: Prüfungstermine
|
||||
ExamFormAutomaticFunctions: Automatische Funktionen
|
||||
ExamFormCorrection: Korrektur
|
||||
ExamFormParts: Teile
|
||||
|
||||
ExamCorrectors: Korrektoren
|
||||
ExamCorrectorAlreadyAdded: Ein Korrektor mit dieser E-Mail ist bereits für diese Klausur eingetragen
|
||||
|
||||
ExamParts: Teilaufgaben
|
||||
ExamPartWeightNegative: Gewicht aller Teilaufgaben muss größer oder gleich Null sein
|
||||
ExamPartAlreadyExists: Teilaufgabe mit diesem Namen existiert bereits
|
||||
ExamPartName: Name
|
||||
ExamPartMaxPoints: Maximalpunktzahl
|
||||
ExamPartWeight: Gewichtung
|
||||
ExamPartResultPoints: Erreichte Punkte
|
||||
|
||||
ExamNameTaken exam@ExamName: Es existiert bereits eine Klausur mit Namen #{exam}
|
||||
ExamCreated exam@ExamName: Klausur #{exam} erfolgreich angelegt
|
||||
ExamEdited exam@ExamName: Klausur #{exam} erfolgreich bearbeitet
|
||||
|
||||
ExamNoShow: Nicht erschienen
|
||||
ExamVoided: Entwertet
|
||||
|
||||
ExamBonusPoints possible@Points: Maximal #{showFixed True possible} Klausurpunkte
|
||||
ExamBonusPointsPassed possible@Points: Maximal #{showFixed True possible} Klausurpunkte, falls die Klausur auch ohne Bonus bereits bestanden ist
|
||||
|
||||
ExamPassed: Bestanden
|
||||
ExamNotPassed: Nicht bestanden
|
||||
ExamResult: Klausurergebnis
|
||||
|
||||
ExamRegisteredSuccess exam@ExamName: Erfolgreich zur Klausur #{exam} angemeldet
|
||||
ExamDeregisteredSuccess exam@ExamName: Erfolgreich von der Klausur #{exam} abgemeldet
|
||||
ExamRegistered: Angemeldet
|
||||
ExamNotRegistered: Nicht angemeldet
|
||||
ExamRegistration: Anmeldung
|
||||
|
||||
ExamRegisterToMustBeAfterRegisterFrom: "Anmeldung ab" muss vor "Anmeldung bis" liegen
|
||||
ExamDeregisterUntilMustBeAfterRegisterFrom: "Abmeldung bis" muss nach "Anmeldung bis" liegen
|
||||
ExamStartMustBeAfterPublishOccurrenceAssignments: Start muss nach Veröffentlichung der Terminzuordnung liegen
|
||||
ExamEndMustBeAfterStart: Beginn der Klausur muss vor ihrem Ende liegen
|
||||
ExamFinishedMustBeAfterEnd: "Bewertung abgeschlossen ab" muss nach Ende liegen
|
||||
ExamFinishedMustBeAfterStart: "Bewertung abgeschlossen ab" muss nach Start liegen
|
||||
ExamClosedMustBeAfterFinished: "Noten stehen fest ab" muss nach "Bewertung abgeschlossen ab" liegen
|
||||
ExamClosedMustBeAfterStart: "Noten stehen fest ab" muss nach Start liegen
|
||||
ExamClosedMustBeAfterEnd: "Noten stehen fest ab" muss nach Ende liegen
|
||||
|
||||
VersionHistory: Versionsgeschichte
|
||||
KnownBugs: Bekannte Bugs
|
||||
|
||||
@ -33,7 +33,7 @@ CourseFavourite -- which user accessed which course when, only display
|
||||
Lecturer -- course ownership
|
||||
user UserId
|
||||
course CourseId
|
||||
type LecturerType default='"lecturer"'
|
||||
type LecturerType default='"lecturer"'::jsonb
|
||||
UniqueLecturer user course -- note: multiple lecturers per course are allowed, but no duplicated rows in this table
|
||||
CourseParticipant -- course enrolement
|
||||
course CourseId
|
||||
|
||||
75
models/exams
75
models/exams
@ -1,22 +1,55 @@
|
||||
-- EXAMS ARE TODO; THIS IS JUST AN UNUSED STUB
|
||||
Exam
|
||||
course CourseId
|
||||
name Text
|
||||
description Text
|
||||
begin UTCTime
|
||||
end UTCTime
|
||||
registrationBegin UTCTime
|
||||
registrationEnd UTCTime
|
||||
deregistrationEnd UTCTime
|
||||
ratingVisible Bool -- may participants see their own rating yet
|
||||
statisticsVisible Bool -- may participants view statistics over all participants (should not be allowed for 'small' courses)
|
||||
--ExamEdit
|
||||
-- user UserId
|
||||
-- time UTCTime
|
||||
-- exam ExamId
|
||||
--ExamUser
|
||||
-- user UserId
|
||||
-- examId ExamId
|
||||
-- -- CONTINUE HERE: Include rating in this table or separately?
|
||||
-- UniqueExamUser user examId
|
||||
-- By default this file is used in Model.hs (which is imported by Foundation.hs)
|
||||
course CourseId
|
||||
name ExamName
|
||||
gradingRule ExamGradingRule
|
||||
bonusRule ExamBonusRule
|
||||
occurrenceRule ExamOccurrenceRule
|
||||
visibleFrom UTCTime Maybe
|
||||
registerFrom UTCTime Maybe
|
||||
registerTo UTCTime Maybe
|
||||
deregisterUntil UTCTime Maybe
|
||||
publishOccurrenceAssignments UTCTime
|
||||
start UTCTime
|
||||
end UTCTime Maybe
|
||||
finished UTCTime Maybe -- Grades shown to students, `ExamCorrector`s locked out
|
||||
closed UTCTime Maybe -- Prüfungsamt hat Einsicht (notification)
|
||||
publicStatistics Bool
|
||||
showGrades Bool
|
||||
description Html Maybe
|
||||
UniqueExam course name
|
||||
ExamPart
|
||||
exam ExamId
|
||||
name (CI Text)
|
||||
maxPoints Points Maybe
|
||||
weight Rational
|
||||
UniqueExamPart exam name
|
||||
ExamOccurrence
|
||||
exam ExamId
|
||||
room Text
|
||||
capacity Natural
|
||||
start UTCTime
|
||||
end UTCTime Maybe
|
||||
description Html Maybe
|
||||
ExamRegistration
|
||||
exam ExamId
|
||||
user UserId
|
||||
occurrence ExamOccurrenceId Maybe
|
||||
UniqueExamRegistration exam user
|
||||
ExamPartResult
|
||||
examPart ExamPartId
|
||||
user UserId
|
||||
result ExamResultPoints
|
||||
UniqueExamPartResult examPart user
|
||||
ExamResult
|
||||
exam ExamId
|
||||
user UserId
|
||||
result ExamResultGrade
|
||||
UniqueExamResult exam user
|
||||
ExamCorrector
|
||||
exam ExamId
|
||||
user UserId
|
||||
UniqueExamCorrector exam user
|
||||
ExamPartCorrector
|
||||
part ExamPartId
|
||||
corrector ExamCorrector
|
||||
UniqueExamPartCorrector part corrector
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user