Merge branch 'master' into course-teaser

This commit is contained in:
Sarah Vaupel 2019-07-03 11:59:02 +02:00
parent d08c03c477
commit 37db6256c1
440 changed files with 32390 additions and 7381 deletions

9
.babelrc Normal file
View 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 }]
]
}

View File

@ -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
View 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
View File

@ -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
View File

@ -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
View 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

View File

@ -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
View 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
View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 98 KiB

View File

@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

View File

@ -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
View File

@ -0,0 +1 @@
module.exports = {extends: ['@commitlint/config-conventional']}

40
config/archive-types Normal file
View 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
View 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

View File

@ -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
View File

@ -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 -- $@

View File

@ -0,0 +1,2 @@
import './fetch';
import './url-search-params';

28
frontend/src/app.js Normal file
View 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
View 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();
});
});
});

View 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
View 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);

View 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) + '__';
}
}

View 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();
});
});
});
});

View 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);
}
}

View 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();
});
});
});
});
});

View 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];
}
}

View 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();
});
});
});

View 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);
}
}

View 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();
}
}

View 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;
}
}

View 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
```

View File

@ -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;

View 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();
});
});

View 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';
}
};
}
}

View 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
...
```

View File

@ -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;
}
}
}

View 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();
});
});

View 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);
});
}
}

View 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.').

View File

@ -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;

View 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();
});
});

View 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];
}

View 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)

View 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();
});
});

View 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;
});
}
}

View 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)

View 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();
});
});

View 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
}
}

View 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
```

View 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();
}
}

View 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 />
```

View 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();
}
}

View 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)

View 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);
}
}

View 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)

View 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
];

View File

@ -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;
}

View 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';
}
}

View 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>
```

View 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;
}
}

View 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)

View 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;
}
}

View 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'>
```

View 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);
}
}

View 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'>
```

View File

@ -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;
}

View 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');
}
}
}

View 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'

View 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,
];

View File

@ -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;
}
}

View File

@ -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;

View 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);
});
}
}

View 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'>
```

View 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);
}
}

View 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
```

View File

@ -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;
}
}

View 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)) || {};
}
}

View 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
```

View File

@ -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;

View File

@ -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) {

View 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() {}
};

View File

@ -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);
}

View 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,
];

View File

@ -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

File diff suppressed because one or more lines are too long

2
frontend/vendor/main.js vendored Normal file
View File

@ -0,0 +1,2 @@
import './fontawesome.css';
import './flatpickr.css';

View File

@ -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

View File

@ -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
View 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
View 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!

View File

@ -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

View File

@ -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

View File

@ -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