Merge remote-tracking branch 'upstream/OpenSlides-3' into new-master
This commit is contained in:
commit
b1fa3ca263
45
.gitignore
vendored
45
.gitignore
vendored
@ -6,7 +6,7 @@
|
|||||||
*~
|
*~
|
||||||
|
|
||||||
# Virtual Environment
|
# Virtual Environment
|
||||||
.virtualenv/*
|
.virtualenv*/*
|
||||||
.venv/*
|
.venv/*
|
||||||
|
|
||||||
# Javascript tools and libraries
|
# Javascript tools and libraries
|
||||||
@ -30,6 +30,7 @@ debug/*
|
|||||||
# Unit test and coverage reports
|
# Unit test and coverage reports
|
||||||
.coverage
|
.coverage
|
||||||
tests/file/*
|
tests/file/*
|
||||||
|
.pytest_cache
|
||||||
|
|
||||||
# Plugin development
|
# Plugin development
|
||||||
openslides_*
|
openslides_*
|
||||||
@ -37,5 +38,43 @@ openslides_*
|
|||||||
# Mypy cache for typechecking
|
# Mypy cache for typechecking
|
||||||
.mypy_cache
|
.mypy_cache
|
||||||
|
|
||||||
# Development of a new client. Easier to switch branches with this entry
|
# OpenSlides 3 Client
|
||||||
client/*
|
|
||||||
|
# compiled output
|
||||||
|
client/dist
|
||||||
|
client/tmp
|
||||||
|
client/out-tsc
|
||||||
|
client/documentation
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
client/node_modules
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
/.idea
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# IDE - VSCode
|
||||||
|
.vscode/*
|
||||||
|
|
||||||
|
# misc
|
||||||
|
Compodoc
|
||||||
|
Compodocmodules
|
||||||
|
client/.sass-cache
|
||||||
|
client/connect.lock
|
||||||
|
client/coverage
|
||||||
|
client/libpeerconnection.log
|
||||||
|
client/npm-debug.log
|
||||||
|
client/yarn-error.log
|
||||||
|
client/testem.log
|
||||||
|
client/typings
|
||||||
|
client/yarn.lock
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# System Files
|
||||||
|
client/.DS_Store
|
||||||
|
client/Thumbs.db
|
||||||
|
92
.travis.yml
92
.travis.yml
@ -1,36 +1,66 @@
|
|||||||
language: python
|
|
||||||
dist: xenial
|
dist: xenial
|
||||||
sudo: true
|
sudo: true
|
||||||
cache:
|
|
||||||
pip: true
|
|
||||||
yarn: true
|
|
||||||
python:
|
|
||||||
- "3.5"
|
|
||||||
- "3.6"
|
|
||||||
- "3.7"
|
|
||||||
env:
|
|
||||||
- TRAVIS_NODE_VERSION="10.5"
|
|
||||||
before_install:
|
|
||||||
- nvm install $TRAVIS_NODE_VERSION
|
|
||||||
- curl -o- -L https://yarnpkg.com/install.sh | bash
|
|
||||||
- export PATH="$HOME/.yarn/bin:$PATH"
|
|
||||||
install:
|
|
||||||
- pip install --upgrade setuptools pip
|
|
||||||
- pip install --upgrade --requirement requirements.txt
|
|
||||||
- pip freeze
|
|
||||||
- yarn
|
|
||||||
- node_modules/.bin/gulp --production
|
|
||||||
script:
|
|
||||||
- flake8 openslides tests
|
|
||||||
- isort --check-only --recursive openslides tests
|
|
||||||
- python -m mypy openslides/
|
|
||||||
- node_modules/.bin/gulp jshint
|
|
||||||
- node_modules/.bin/karma start --browsers PhantomJS tests/karma/karma.conf.js
|
|
||||||
|
|
||||||
- DJANGO_SETTINGS_MODULE='tests.settings' coverage run ./manage.py test tests.unit
|
matrix:
|
||||||
- coverage report --fail-under=35
|
include:
|
||||||
|
- language: python
|
||||||
|
cache:
|
||||||
|
pip: true
|
||||||
|
python:
|
||||||
|
- "3.6"
|
||||||
|
install:
|
||||||
|
- python --version
|
||||||
|
- pip install --upgrade setuptools pip
|
||||||
|
- pip install --upgrade --requirement requirements/development.txt
|
||||||
|
- pip install --upgrade .[big_mode]
|
||||||
|
- pip freeze
|
||||||
|
script:
|
||||||
|
- flake8 openslides tests
|
||||||
|
- isort --check-only --diff --recursive openslides tests
|
||||||
|
- python -m mypy openslides/
|
||||||
|
- pytest tests/old/ tests/integration/ tests/unit/ --cov --cov-fail-under=76
|
||||||
|
|
||||||
- DJANGO_SETTINGS_MODULE='tests.settings' coverage run ./manage.py test tests.integration
|
- language: python
|
||||||
- coverage report --fail-under=73
|
cache:
|
||||||
|
pip: true
|
||||||
|
python:
|
||||||
|
- "3.7"
|
||||||
|
install:
|
||||||
|
- python --version
|
||||||
|
- pip install --upgrade setuptools pip
|
||||||
|
- pip install --upgrade --requirement requirements/development.txt
|
||||||
|
- pip install --upgrade .[big_mode]
|
||||||
|
- pip freeze
|
||||||
|
script:
|
||||||
|
- flake8 openslides tests
|
||||||
|
- isort --check-only --diff --recursive openslides tests
|
||||||
|
- python -m mypy openslides/
|
||||||
|
- pytest tests/old/ tests/integration/ tests/unit/ --cov --cov-fail-under=76
|
||||||
|
|
||||||
- DJANGO_SETTINGS_MODULE='tests.settings' ./manage.py test tests.old
|
- language: node_js
|
||||||
|
node_js:
|
||||||
|
- "9"
|
||||||
|
apt:
|
||||||
|
sources:
|
||||||
|
- google-chrome
|
||||||
|
packages:
|
||||||
|
- google-chrome-stable
|
||||||
|
cache:
|
||||||
|
yarn: true
|
||||||
|
directories:
|
||||||
|
- $HOME/.yarn-cache
|
||||||
|
- node_modules
|
||||||
|
before_install:
|
||||||
|
- sh -e /etc/init.d/xvfb start
|
||||||
|
- export CHROME_BIN=/usr/bin/google-chrome
|
||||||
|
- export DISPLAY=:99.0
|
||||||
|
- curl -o- -L https://yarnpkg.com/install.sh | bash
|
||||||
|
- export PATH="$HOME/.yarn/bin:$PATH"
|
||||||
|
- yarn global add @angular/cli
|
||||||
|
- ng --version
|
||||||
|
- cd client
|
||||||
|
install:
|
||||||
|
- yarn install
|
||||||
|
script:
|
||||||
|
- yarn run lint
|
||||||
|
- yarn run test --watch=false
|
||||||
|
@ -4,6 +4,28 @@
|
|||||||
|
|
||||||
https://openslides.org/
|
https://openslides.org/
|
||||||
|
|
||||||
|
Version 3.0 (unreleased)
|
||||||
|
========================
|
||||||
|
|
||||||
|
Core:
|
||||||
|
- Changed personal settings.py, updated to channels2, complete rework of
|
||||||
|
startup and caching system, dropped support for Geiss [#3796, #3789].
|
||||||
|
- Dropped support for Python 3.5 [#3805].
|
||||||
|
- Added a websocket protocol for server client communication using
|
||||||
|
JSON schema [#3807].
|
||||||
|
- Changed URL schema [#3798].
|
||||||
|
- Enabled docs for using OpenSlides with Gunicorn and Uvicorn in big
|
||||||
|
mode [#3799, #3817].
|
||||||
|
|
||||||
|
Motions:
|
||||||
|
- Option to customly sort motions [#3894].
|
||||||
|
- Added support for adding a statute [#3894].
|
||||||
|
|
||||||
|
User:
|
||||||
|
- Added new admin group which grants all permissions. Users of existing group
|
||||||
|
'Admin' or 'Staff' are move to the new group during migration [#3859].
|
||||||
|
|
||||||
|
|
||||||
Version 2.3 (2018-09-20)
|
Version 2.3 (2018-09-20)
|
||||||
========================
|
========================
|
||||||
`Release notes <https://github.com/OpenSlides/OpenSlides/wiki/OpenSlides-2.3>`_ ·
|
`Release notes <https://github.com/OpenSlides/OpenSlides/wiki/OpenSlides-2.3>`_ ·
|
||||||
|
@ -15,7 +15,7 @@ Installation and start of the development version
|
|||||||
a. Check requirements
|
a. Check requirements
|
||||||
'''''''''''''''''''''
|
'''''''''''''''''''''
|
||||||
|
|
||||||
Make sure that you have installed `Python (>= 3.5) <https://www.python.org/>`_,
|
Make sure that you have installed `Python (>= 3.6) <https://www.python.org/>`_,
|
||||||
`Node.js (>=4.x) <https://nodejs.org/>`_, `Yarn <https://yarnpkg.com/>`_ and
|
`Node.js (>=4.x) <https://nodejs.org/>`_, `Yarn <https://yarnpkg.com/>`_ and
|
||||||
`Git <http://git-scm.com/>`_ on your system. You also need build-essential
|
`Git <http://git-scm.com/>`_ on your system. You also need build-essential
|
||||||
packages and header files and a static library for Python.
|
packages and header files and a static library for Python.
|
||||||
@ -25,9 +25,6 @@ For Ubuntu 16.04 e. g. follow `Yarn installation instructions
|
|||||||
|
|
||||||
$ sudo apt-get install git nodejs nodejs-legacy npm build-essential python3-dev
|
$ sudo apt-get install git nodejs nodejs-legacy npm build-essential python3-dev
|
||||||
|
|
||||||
*Note: For Ubuntu 14.04 you have to update Node.js before. The distribution
|
|
||||||
version is 0.10.25 which is not sufficient.*
|
|
||||||
|
|
||||||
|
|
||||||
b. Get OpenSlides source code
|
b. Get OpenSlides source code
|
||||||
'''''''''''''''''''''''''''''
|
'''''''''''''''''''''''''''''
|
||||||
@ -78,7 +75,7 @@ To get help on the command line options run::
|
|||||||
|
|
||||||
Later you might want to restart the server with one of the following commands.
|
Later you might want to restart the server with one of the following commands.
|
||||||
|
|
||||||
To start OpenSlides with Daphne and one worker and to avoid opening new browser
|
To start OpenSlides with Daphne and to avoid opening new browser
|
||||||
windows run::
|
windows run::
|
||||||
|
|
||||||
$ python manage.py start --no-browser
|
$ python manage.py start --no-browser
|
||||||
@ -87,16 +84,10 @@ When debugging something email related change the email backend to console::
|
|||||||
|
|
||||||
$ python manage.py start --debug-email
|
$ python manage.py start --debug-email
|
||||||
|
|
||||||
To start OpenSlides with Daphne and four workers (avoid concurrent write
|
To start OpenSlides with Daphne run::
|
||||||
requests or use PostgreSQL, see below) run::
|
|
||||||
|
|
||||||
$ python manage.py runserver
|
$ python manage.py runserver
|
||||||
|
|
||||||
To start OpenSlides with Geiss and one worker and to avoid opening new browser
|
|
||||||
windows (download Geiss and setup Redis before, see below) run::
|
|
||||||
|
|
||||||
$ python manage.py start --no-browser --use-geiss
|
|
||||||
|
|
||||||
Use gulp watch in a second command-line interface::
|
Use gulp watch in a second command-line interface::
|
||||||
|
|
||||||
$ node_modules/.bin/gulp watch
|
$ node_modules/.bin/gulp watch
|
||||||
@ -108,7 +99,7 @@ Use gulp watch in a second command-line interface::
|
|||||||
Follow the instructions above (Installation on GNU/Linux or Mac OS X) but care
|
Follow the instructions above (Installation on GNU/Linux or Mac OS X) but care
|
||||||
of the following variations.
|
of the following variations.
|
||||||
|
|
||||||
To get Python download and run the latest `Python 3.5 32-bit (x86) executable
|
To get Python download and run the latest `Python 3.7 32-bit (x86) executable
|
||||||
installer <https://www.python.org/downloads/windows/>`_. Note that the 32-bit
|
installer <https://www.python.org/downloads/windows/>`_. Note that the 32-bit
|
||||||
installer is required even on a 64-bit Windows system. If you use the 64-bit
|
installer is required even on a 64-bit Windows system. If you use the 64-bit
|
||||||
installer, step d. of the instruction might fail unless you installed some
|
installer, step d. of the instruction might fail unless you installed some
|
||||||
@ -152,8 +143,7 @@ OpenSlides in big mode
|
|||||||
|
|
||||||
In the so called big mode you should use OpenSlides with Redis, PostgreSQL and a
|
In the so called big mode you should use OpenSlides with Redis, PostgreSQL and a
|
||||||
webserver like Apache HTTP Server or nginx as proxy server in front of your
|
webserver like Apache HTTP Server or nginx as proxy server in front of your
|
||||||
OpenSlides interface server. Optionally you can use `Geiss
|
OpenSlides interface server.
|
||||||
<https://github.com/ostcar/geiss/>`_ as interface server instead of Daphne.
|
|
||||||
|
|
||||||
|
|
||||||
1. Install and configure PostgreSQL and Redis
|
1. Install and configure PostgreSQL and Redis
|
||||||
@ -173,15 +163,8 @@ Then add database user and database. For Ubuntu 16.04 e. g. run::
|
|||||||
$ sudo -u postgres createdb --owner=openslides openslides
|
$ sudo -u postgres createdb --owner=openslides openslides
|
||||||
|
|
||||||
|
|
||||||
2. Install additional packages
|
|
||||||
------------------------------
|
|
||||||
|
|
||||||
Install some more required Python packages::
|
2. Change OpenSlides settings
|
||||||
|
|
||||||
$ pip install -r requirements_big_mode.txt
|
|
||||||
|
|
||||||
|
|
||||||
3. Change OpenSlides settings
|
|
||||||
-----------------------------
|
-----------------------------
|
||||||
|
|
||||||
Create OpenSlides settings file if it does not exist::
|
Create OpenSlides settings file if it does not exist::
|
||||||
@ -197,34 +180,26 @@ Populate your new database::
|
|||||||
$ python manage.py migrate
|
$ python manage.py migrate
|
||||||
|
|
||||||
|
|
||||||
4. Run OpenSlides
|
3. Run OpenSlides
|
||||||
-----------------
|
-----------------
|
||||||
|
|
||||||
First start e. g. four workers (do not use the `--threads` option, because the threads will not spawn across all cores)::
|
To start gunicorn with uvicorn as protocol server run::
|
||||||
|
|
||||||
$ python manage.py runworker&
|
|
||||||
$ python manage.py runworker&
|
|
||||||
$ python manage.py runworker&
|
|
||||||
$ python manage.py runworker&
|
|
||||||
|
|
||||||
To start Daphne as protocol server run::
|
|
||||||
|
|
||||||
$ export DJANGO_SETTINGS_MODULE=settings
|
$ export DJANGO_SETTINGS_MODULE=settings
|
||||||
$ export PYTHONPATH=personal_data/var/
|
$ export PYTHONPATH=personal_data/var/
|
||||||
$ daphne openslides.asgi:channel_layer
|
$ gunicorn -w 4 -k uvicorn.workers.UvicornWorker openslides.asgi:application
|
||||||
|
|
||||||
To use Geiss instead of Daphne, just download Geiss and start it::
|
This example uses 4 instances. The recommendation is to use CPU cores * 2.
|
||||||
|
|
||||||
$ python manage.py getgeiss
|
|
||||||
$ ./personal_data/var/geiss
|
|
||||||
|
|
||||||
5. Use Nginx (optional)
|
4. Use Nginx (optional)
|
||||||
|
-----------------------
|
||||||
|
|
||||||
When using Nginx as a proxy for delivering staticfiles the performance of the setup will increase very much. For delivering staticfiles you have to collect those::
|
When using Nginx as a proxy for delivering staticfiles the performance of the setup will increase very much. For delivering staticfiles you have to collect those::
|
||||||
|
|
||||||
$ python manage.py collectstatic
|
$ python manage.py collectstatic
|
||||||
|
|
||||||
This is an example configuration for a single Daphne/Geiss listen on port 8000::
|
This is an example configuration for a single Daphne listen on port 8000::
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
@ -232,9 +207,6 @@ This is an example configuration for a single Daphne/Geiss listen on port 8000::
|
|||||||
|
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
location ~* ^/(?!ws|wss|webclient|core/servertime|core/version|users/whoami|users/login|users/logout|users/setpassword|motions/docxtemplate|agenda/docxtemplate|projector|real-projector|static|media|rest).*$ {
|
|
||||||
rewrite ^.*$ /static/templates/index.html;
|
|
||||||
}
|
|
||||||
location ~* ^/projector.*$ {
|
location ~* ^/projector.*$ {
|
||||||
rewrite ^.*$ /static/templates/projector-container.html;
|
rewrite ^.*$ /static/templates/projector-container.html;
|
||||||
}
|
}
|
||||||
@ -247,6 +219,9 @@ This is an example configuration for a single Daphne/Geiss listen on port 8000::
|
|||||||
location /static {
|
location /static {
|
||||||
alias <your path to>/collected-static;
|
alias <your path to>/collected-static;
|
||||||
}
|
}
|
||||||
|
location ~* ^/(?!ws|wss|media|rest|views).*$ {
|
||||||
|
rewrite ^.*$ /static/templates/index.html;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://localhost:8000;
|
proxy_pass http://localhost:8000;
|
||||||
@ -258,10 +233,3 @@ This is an example configuration for a single Daphne/Geiss listen on port 8000::
|
|||||||
proxy_set_header X-Scheme $scheme;
|
proxy_set_header X-Scheme $scheme;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Using Nginx as a load balancer is fairly easy. Just start multiple Daphnes/Geiss on different ports, change the `proxy_pass` to `http://openslides/` and add this on top of the Nginx configuration::
|
|
||||||
|
|
||||||
upstream openslides {
|
|
||||||
server localhost:2001;
|
|
||||||
server localhost:2002;
|
|
||||||
}
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
FROM python:3.5
|
FROM python:3.7-slim
|
||||||
RUN apt-get -y update && apt-get -y upgrade
|
RUN apt-get -y update && \
|
||||||
RUN apt-get install -y libpq-dev supervisor curl vim
|
apt-get -y upgrade && \
|
||||||
|
apt-get install -y libpq-dev supervisor curl wget xz-utils bzip2 git gcc
|
||||||
RUN useradd -m openslides
|
RUN useradd -m openslides
|
||||||
|
|
||||||
## BUILD JS STUFF
|
## BUILD JS STUFF
|
||||||
@ -18,7 +19,7 @@ RUN node_modules/.bin/gulp --production
|
|||||||
|
|
||||||
# INSTALL PYTHON DEPENDENCIES
|
# INSTALL PYTHON DEPENDENCIES
|
||||||
USER root
|
USER root
|
||||||
RUN pip install -r /app/requirements_big_mode.txt
|
RUN pip install .[big_mode]
|
||||||
|
|
||||||
## Clean up
|
## Clean up
|
||||||
RUN apt-get remove -y python3-pip wget curl
|
RUN apt-get remove -y python3-pip wget curl
|
||||||
|
@ -2,8 +2,8 @@ include AUTHORS
|
|||||||
include CHANGELOG
|
include CHANGELOG
|
||||||
include LICENSE
|
include LICENSE
|
||||||
include README.rst
|
include README.rst
|
||||||
include requirements_production.txt
|
include requirements/production.txt
|
||||||
include requirements_big_mode.txt
|
include requirements/big_mode.txt
|
||||||
include bower.json
|
include bower.json
|
||||||
recursive-include openslides *.*
|
recursive-include openslides *.*
|
||||||
exclude openslides/__pycache__/*
|
exclude openslides/__pycache__/*
|
||||||
|
12
README.rst
12
README.rst
@ -26,7 +26,7 @@ Installation
|
|||||||
a. Check requirements
|
a. Check requirements
|
||||||
'''''''''''''''''''''
|
'''''''''''''''''''''
|
||||||
|
|
||||||
Make sure that you have installed `Python (>= 3.5) <https://www.python.org/>`_
|
Make sure that you have installed `Python (>= 3.6) <https://www.python.org/>`_
|
||||||
on your system.
|
on your system.
|
||||||
|
|
||||||
Additional you need build-essential packages, header files and a static
|
Additional you need build-essential packages, header files and a static
|
||||||
@ -72,7 +72,7 @@ compressed tar archive and run::
|
|||||||
$ pip install openslides-x.y.tar.gz
|
$ pip install openslides-x.y.tar.gz
|
||||||
|
|
||||||
This will install all required Python packages (see
|
This will install all required Python packages (see
|
||||||
``requirements_production.txt``).
|
``requirements/production.txt``).
|
||||||
|
|
||||||
|
|
||||||
d. Start OpenSlides
|
d. Start OpenSlides
|
||||||
@ -139,23 +139,21 @@ file (usually called settings.py).
|
|||||||
|
|
||||||
The configuration values that have to be altered are:
|
The configuration values that have to be altered are:
|
||||||
|
|
||||||
* CACHES
|
|
||||||
* CHANNEL_LAYERS
|
* CHANNEL_LAYERS
|
||||||
* DATABASES
|
* DATABASES
|
||||||
* SESSION_ENGINE
|
* SESSION_ENGINE
|
||||||
|
* REDIS_ADDRESS
|
||||||
|
|
||||||
You should use a webserver like Apache HTTP Server or nginx to serve the
|
You should use a webserver like Apache HTTP Server or nginx to serve the
|
||||||
static and media files as proxy server in front of your OpenSlides
|
static and media files as proxy server in front of your OpenSlides
|
||||||
interface server. You also should use a database like PostgreSQL and Redis
|
interface server. You also should use a database like PostgreSQL and Redis
|
||||||
as channels backend, cache backend and session engine. Finally you should
|
as channels backend, cache backend and session engine. Finally you should
|
||||||
start some WSGI workers and one or more interface servers (Daphne or Geiss).
|
use gunicorn with uvicorn as interface server.
|
||||||
|
|
||||||
Please see the respective section in the `DEVELOPMENT.rst
|
Please see the respective section in the `DEVELOPMENT.rst
|
||||||
<https://github.com/OpenSlides/OpenSlides/blob/master/DEVELOPMENT.rst>`_ and:
|
<https://github.com/OpenSlides/OpenSlides/blob/master/DEVELOPMENT.rst>`_ and:
|
||||||
|
|
||||||
* https://channels.readthedocs.io/en/latest/deploying.html
|
* https://channels.readthedocs.io/en/latest/deploying.html
|
||||||
* https://github.com/ostcar/geiss
|
|
||||||
* https://docs.djangoproject.com/en/1.10/topics/cache/
|
|
||||||
* https://github.com/sebleier/django-redis-cache
|
* https://github.com/sebleier/django-redis-cache
|
||||||
* https://docs.djangoproject.com/en/1.10/ref/settings/#databases
|
* https://docs.djangoproject.com/en/1.10/ref/settings/#databases
|
||||||
|
|
||||||
@ -165,7 +163,7 @@ Used software
|
|||||||
|
|
||||||
OpenSlides uses the following projects or parts of them:
|
OpenSlides uses the following projects or parts of them:
|
||||||
|
|
||||||
* Several Python packages (see ``requirements_production.txt``).
|
* Several Python packages (see ``requirements/production.txt`` and ``requirements/big_mode.txt``).
|
||||||
|
|
||||||
* Several JavaScript packages (see ``bower.json``)
|
* Several JavaScript packages (see ``bower.json``)
|
||||||
|
|
||||||
|
13
client/.editorconfig
Normal file
13
client/.editorconfig
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# Editor configuration, see http://editorconfig.org
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
max_line_length = off
|
||||||
|
trim_trailing_whitespace = false
|
8
client/.prettierrc
Normal file
8
client/.prettierrc
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 120,
|
||||||
|
"singleQuote": true,
|
||||||
|
"useTabs": false,
|
||||||
|
"tabWidth": 4,
|
||||||
|
"semi": true,
|
||||||
|
"bracketSpacing": true
|
||||||
|
}
|
56
client/README.md
Normal file
56
client/README.md
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# OpenSlides 3 Client
|
||||||
|
|
||||||
|
Prototype application for OpenSlides 3.0 (Client).
|
||||||
|
Currently under constant heavy maintenance.
|
||||||
|
|
||||||
|
## Development Info
|
||||||
|
|
||||||
|
As an Angular project, Angular CLI is highly recommended to create components and services.
|
||||||
|
See https://angular.io/guide/quickstart for details.
|
||||||
|
|
||||||
|
### Contribution Info
|
||||||
|
|
||||||
|
Please respect the code-style defined in `.editorconf` and `.pretierrc`.
|
||||||
|
|
||||||
|
Code alignment should be automatically corrected by the pre-commit hooks.
|
||||||
|
Adjust your editor to the `.editorconfig` to avoid surprises.
|
||||||
|
See https://editorconfig.org/ for details.
|
||||||
|
|
||||||
|
### Pre-Commit Hooks
|
||||||
|
|
||||||
|
Before commiting, new code will automatically be aligned to the definitions set in the
|
||||||
|
`.prettierrc`.
|
||||||
|
Furthermore, new code has to pass linting.
|
||||||
|
|
||||||
|
Our pre-commit hooks are:
|
||||||
|
`pretty-quick --staged` and `lint`
|
||||||
|
See `package.json` for details.
|
||||||
|
|
||||||
|
### Documentation Info
|
||||||
|
|
||||||
|
The documentation can be generated by running `npm run compodoc`.
|
||||||
|
A new web server will be started on http://localhost:8080
|
||||||
|
Once running, the documentation will be updated automatically.
|
||||||
|
|
||||||
|
You can run it on another port, with adding your local port after the
|
||||||
|
command. If no port specified, it will try to use 8080.
|
||||||
|
|
||||||
|
Please document new code using JSDoc tags.
|
||||||
|
See https://compodoc.app/guides/jsdoc-tags.html for details.
|
||||||
|
|
||||||
|
### Development server
|
||||||
|
|
||||||
|
Run `npm start` for a development server. Navigate to `http://localhost:4200/`.
|
||||||
|
The app will automatically reload if you change any of the source files.
|
||||||
|
|
||||||
|
A running OpenSlides (2.2 or higher) instance is expected on port 8000.
|
||||||
|
|
||||||
|
Start OpenSlides as usual using
|
||||||
|
`python manage.py start --no-browser --host 0.0.0.0`
|
||||||
|
|
||||||
|
### Translation
|
||||||
|
|
||||||
|
We are using ngx-translate for translation purposes.
|
||||||
|
Use `npm run extract` to extract strings and update elements an with translation functions.
|
||||||
|
|
||||||
|
Language files can be found in `/src/assets/i18n`.
|
115
client/angular.json
Normal file
115
client/angular.json
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
|
"version": 1,
|
||||||
|
"newProjectRoot": "projects",
|
||||||
|
"projects": {
|
||||||
|
"client": {
|
||||||
|
"root": "",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"projectType": "application",
|
||||||
|
"prefix": "os",
|
||||||
|
"schematics": {
|
||||||
|
"@schematics/angular:component": {
|
||||||
|
"styleext": "scss"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular-devkit/build-angular:browser",
|
||||||
|
"options": {
|
||||||
|
"outputPath": "dist/client",
|
||||||
|
"index": "src/index.html",
|
||||||
|
"main": "src/main.ts",
|
||||||
|
"polyfills": "src/polyfills.ts",
|
||||||
|
"tsConfig": "src/tsconfig.app.json",
|
||||||
|
"assets": ["src/favicon.ico", "src/assets"],
|
||||||
|
"styles": ["src/styles.scss", "node_modules/material-design-icons/iconfont/material-icons.css"],
|
||||||
|
"scripts": []
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.prod.ts"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"optimization": true,
|
||||||
|
"outputHashing": "all",
|
||||||
|
"sourceMap": false,
|
||||||
|
"extractCss": true,
|
||||||
|
"namedChunks": false,
|
||||||
|
"aot": true,
|
||||||
|
"extractLicenses": true,
|
||||||
|
"vendorChunk": false,
|
||||||
|
"buildOptimizer": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"builder": "@angular-devkit/build-angular:dev-server",
|
||||||
|
"options": {
|
||||||
|
"browserTarget": "client:build"
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"browserTarget": "client:build:production"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"extract-i18n": {
|
||||||
|
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||||
|
"options": {
|
||||||
|
"browserTarget": "client:build"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"builder": "@angular-devkit/build-angular:karma",
|
||||||
|
"options": {
|
||||||
|
"main": "src/test.ts",
|
||||||
|
"polyfills": "src/polyfills.ts",
|
||||||
|
"tsConfig": "src/tsconfig.spec.json",
|
||||||
|
"karmaConfig": "src/karma.conf.js",
|
||||||
|
"styles": ["src/styles.css"],
|
||||||
|
"scripts": [],
|
||||||
|
"assets": ["src/favicon.ico", "src/assets"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"builder": "@angular-devkit/build-angular:tslint",
|
||||||
|
"options": {
|
||||||
|
"tsConfig": "src/tsconfig.spec.json",
|
||||||
|
"format": "stylish",
|
||||||
|
"exclude": ["**/node_modules/**"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"client-e2e": {
|
||||||
|
"root": "e2e/",
|
||||||
|
"projectType": "application",
|
||||||
|
"architect": {
|
||||||
|
"e2e": {
|
||||||
|
"builder": "@angular-devkit/build-angular:protractor",
|
||||||
|
"options": {
|
||||||
|
"protractorConfig": "e2e/protractor.conf.js",
|
||||||
|
"devServerTarget": "client:serve"
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"devServerTarget": "client:serve:production"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"builder": "@angular-devkit/build-angular:tslint",
|
||||||
|
"options": {
|
||||||
|
"tsConfig": "e2e/tsconfig.e2e.json",
|
||||||
|
"exclude": ["**/node_modules/**"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultProject": "client"
|
||||||
|
}
|
26
client/e2e/protractor.conf.js
Normal file
26
client/e2e/protractor.conf.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
// Protractor configuration file, see link for more information
|
||||||
|
// https://github.com/angular/protractor/blob/master/lib/config.ts
|
||||||
|
|
||||||
|
const { SpecReporter } = require('jasmine-spec-reporter');
|
||||||
|
|
||||||
|
exports.config = {
|
||||||
|
allScriptsTimeout: 11000,
|
||||||
|
specs: ['./src/**/*.e2e-spec.ts'],
|
||||||
|
capabilities: {
|
||||||
|
browserName: 'chrome'
|
||||||
|
},
|
||||||
|
directConnect: true,
|
||||||
|
baseUrl: 'http://localhost:4200/',
|
||||||
|
framework: 'jasmine',
|
||||||
|
jasmineNodeOpts: {
|
||||||
|
showColors: true,
|
||||||
|
defaultTimeoutInterval: 30000,
|
||||||
|
print: function() {}
|
||||||
|
},
|
||||||
|
onPrepare() {
|
||||||
|
require('ts-node').register({
|
||||||
|
project: require('path').join(__dirname, './tsconfig.e2e.json')
|
||||||
|
});
|
||||||
|
jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } }));
|
||||||
|
}
|
||||||
|
};
|
14
client/e2e/src/app.e2e-spec.ts
Normal file
14
client/e2e/src/app.e2e-spec.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { AppPage } from './app.po';
|
||||||
|
|
||||||
|
describe('workspace-project App', () => {
|
||||||
|
let page: AppPage;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
page = new AppPage();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display welcome message', () => {
|
||||||
|
page.navigateTo();
|
||||||
|
expect(page.getParagraphText()).toEqual('Welcome to client!');
|
||||||
|
});
|
||||||
|
});
|
11
client/e2e/src/app.po.ts
Normal file
11
client/e2e/src/app.po.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { browser, by, element } from 'protractor';
|
||||||
|
|
||||||
|
export class AppPage {
|
||||||
|
navigateTo() {
|
||||||
|
return browser.get('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
getParagraphText() {
|
||||||
|
return element(by.css('os-root h1')).getText();
|
||||||
|
}
|
||||||
|
}
|
9
client/e2e/tsconfig.e2e.json
Normal file
9
client/e2e/tsconfig.e2e.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../out-tsc/app",
|
||||||
|
"module": "commonjs",
|
||||||
|
"target": "es5",
|
||||||
|
"types": ["jasmine", "jasminewd2", "node"]
|
||||||
|
}
|
||||||
|
}
|
13266
client/package-lock.json
generated
Normal file
13266
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
68
client/package.json
Normal file
68
client/package.json
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"name": "OpenSlides3-Client",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"ng": "ng",
|
||||||
|
"start": "ng serve --proxy-config proxy.conf.json --host=0.0.0.0 --aot",
|
||||||
|
"build": "ng build",
|
||||||
|
"test": "ng test",
|
||||||
|
"lint": "ng lint",
|
||||||
|
"e2e": "ng e2e",
|
||||||
|
"compodoc": "./node_modules/.bin/compodoc --hideGenerator -p src/tsconfig.app.json -n 'OpenSlides Documentation' -d ../Compodoc -s -w -t -o --port",
|
||||||
|
"extract": "ngx-translate-extract -i ./src -o ./src/assets/i18n/{en,de,fr}.json --clean --sort --format-indentation ' ' --format namespaced-json",
|
||||||
|
"format:fix": "pretty-quick --staged",
|
||||||
|
"precommit": "run-s format:fix lint"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@angular/animations": "^7.0.0-rc.0",
|
||||||
|
"@angular/cdk": "^7.0.0-beta.2",
|
||||||
|
"@angular/common": "^7.0.0-rc.0",
|
||||||
|
"@angular/compiler": "^7.0.0-rc.0",
|
||||||
|
"@angular/core": "^7.0.0-rc.0",
|
||||||
|
"@angular/forms": "^7.0.0-rc.0",
|
||||||
|
"@angular/http": "^7.0.0-rc.0",
|
||||||
|
"@angular/material": "^7.0.0-beta.2",
|
||||||
|
"@angular/platform-browser": "^7.0.0-rc.0",
|
||||||
|
"@angular/platform-browser-dynamic": "^7.0.0-rc.0",
|
||||||
|
"@angular/router": "^7.0.0-rc.0",
|
||||||
|
"@ngx-pwa/local-storage": "^6.1.1",
|
||||||
|
"@ngx-translate/core": "^10.0.2",
|
||||||
|
"@ngx-translate/http-loader": "^3.0.1",
|
||||||
|
"core-js": "^2.5.4",
|
||||||
|
"material-design-icons": "^3.0.1",
|
||||||
|
"ngx-mat-select-search": "^1.4.0",
|
||||||
|
"roboto-fontface": "^0.10.0",
|
||||||
|
"rxjs": "^6.3.3",
|
||||||
|
"uuid": "^3.3.2",
|
||||||
|
"zone.js": "^0.8.26"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular-devkit/build-angular": "^0.7.0",
|
||||||
|
"@angular/cli": "^7.0.0-beta.4",
|
||||||
|
"@angular/compiler-cli": "^7.0.0-rc.0",
|
||||||
|
"@angular/language-service": "^7.0.0-rc.0",
|
||||||
|
"@biesbjerg/ngx-translate-extract": "^2.3.4",
|
||||||
|
"@compodoc/compodoc": "^1.1.5",
|
||||||
|
"@types/jasmine": "~2.8.6",
|
||||||
|
"@types/jasminewd2": "^2.0.4",
|
||||||
|
"@types/node": "~8.9.4",
|
||||||
|
"codelyzer": "~4.2.1",
|
||||||
|
"husky": "^0.14.3",
|
||||||
|
"jasmine-core": "~2.99.1",
|
||||||
|
"jasmine-spec-reporter": "~4.2.1",
|
||||||
|
"karma": "^3.0.0",
|
||||||
|
"karma-chrome-launcher": "~2.2.0",
|
||||||
|
"karma-coverage-istanbul-reporter": "^2.0.4",
|
||||||
|
"karma-jasmine": "~1.1.1",
|
||||||
|
"karma-jasmine-html-reporter": "^0.2.2",
|
||||||
|
"npm-run-all": "^4.1.3",
|
||||||
|
"prettier": "^1.14.3",
|
||||||
|
"pretty-quick": "^1.7.0",
|
||||||
|
"protractor": "^5.4.1",
|
||||||
|
"ts-node": "~5.0.1",
|
||||||
|
"tslint": "~5.9.1",
|
||||||
|
"tsutils": "^3.0.0",
|
||||||
|
"typescript": "^3.1.1"
|
||||||
|
}
|
||||||
|
}
|
19
client/proxy.conf.json
Normal file
19
client/proxy.conf.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"/apps/": {
|
||||||
|
"target": "http://localhost:8000",
|
||||||
|
"secure": false
|
||||||
|
},
|
||||||
|
"/media/": {
|
||||||
|
"target": "http://localhost:8000",
|
||||||
|
"secure": false
|
||||||
|
},
|
||||||
|
"/rest/": {
|
||||||
|
"target": "http://localhost:8000",
|
||||||
|
"secure": false
|
||||||
|
},
|
||||||
|
"/ws/site/": {
|
||||||
|
"target": "ws://localhost:8000",
|
||||||
|
"secure": false,
|
||||||
|
"ws": true
|
||||||
|
}
|
||||||
|
}
|
13
client/src/app/app-routing.module.spec.ts
Normal file
13
client/src/app/app-routing.module.spec.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { AppRoutingModule } from './app-routing.module';
|
||||||
|
|
||||||
|
describe('AppRoutingModule', () => {
|
||||||
|
let appRoutingModule: AppRoutingModule;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
appRoutingModule = new AppRoutingModule();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create an instance', () => {
|
||||||
|
expect(appRoutingModule).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
32
client/src/app/app-routing.module.ts
Normal file
32
client/src/app/app-routing.module.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { RouterModule, Routes } from '@angular/router';
|
||||||
|
|
||||||
|
import { LoginComponent } from './site/login/components/login-wrapper/login.component';
|
||||||
|
import { LoginMaskComponent } from './site/login/components/login-mask/login-mask.component';
|
||||||
|
import { LoginLegalNoticeComponent } from './site/login/components/login-legal-notice/login-legal-notice.component';
|
||||||
|
import { LoginPrivacyPolicyComponent } from './site/login/components/login-privacy-policy/login-privacy-policy.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global app routing
|
||||||
|
*/
|
||||||
|
const routes: Routes = [
|
||||||
|
{
|
||||||
|
path: 'login',
|
||||||
|
component: LoginComponent,
|
||||||
|
children: [
|
||||||
|
{ path: '', component: LoginMaskComponent },
|
||||||
|
{ path: 'legalnotice', component: LoginLegalNoticeComponent },
|
||||||
|
{ path: 'privacypolicy', component: LoginPrivacyPolicyComponent }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
{ path: 'projector', loadChildren: './projector-container/projector-container.module#ProjectorContainerModule' },
|
||||||
|
{ path: '', loadChildren: './site/site.module#SiteModule' },
|
||||||
|
{ path: '**', redirectTo: '' }
|
||||||
|
];
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [RouterModule.forRoot(routes)],
|
||||||
|
exports: [RouterModule]
|
||||||
|
})
|
||||||
|
export class AppRoutingModule {}
|
3
client/src/app/app.component.html
Normal file
3
client/src/app/app.component.html
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<div class="content">
|
||||||
|
<router-outlet></router-outlet>
|
||||||
|
</div>
|
3
client/src/app/app.component.scss
Normal file
3
client/src/app/app.component.scss
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
15
client/src/app/app.component.spec.ts
Normal file
15
client/src/app/app.component.spec.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { TestBed, async } from '@angular/core/testing';
|
||||||
|
import { AppComponent } from './app.component';
|
||||||
|
import { E2EImportsModule } from './../e2e-imports.module';
|
||||||
|
describe('AppComponent', () => {
|
||||||
|
beforeEach(async(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [E2EImportsModule]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
it('should create the app', async(() => {
|
||||||
|
const fixture = TestBed.createComponent(AppComponent);
|
||||||
|
const app = fixture.debugElement.componentInstance;
|
||||||
|
expect(app).toBeTruthy();
|
||||||
|
}));
|
||||||
|
});
|
75
client/src/app/app.component.ts
Normal file
75
client/src/app/app.component.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
import { OperatorService } from './core/services/operator.service';
|
||||||
|
import { LoginDataService } from './core/services/login-data.service';
|
||||||
|
import { ConfigService } from './core/services/config.service';
|
||||||
|
import { ConstantsService } from './core/services/constants.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Angular's global App Component
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'os-root',
|
||||||
|
templateUrl: './app.component.html',
|
||||||
|
styleUrls: ['./app.component.scss']
|
||||||
|
})
|
||||||
|
export class AppComponent {
|
||||||
|
/**
|
||||||
|
* Master-component of all apps.
|
||||||
|
*
|
||||||
|
* Inits the translation service, the operator, the login data and the constants.
|
||||||
|
*
|
||||||
|
* Handles the altering of Array.toString()
|
||||||
|
*
|
||||||
|
* @param autoupdateService
|
||||||
|
* @param notifyService
|
||||||
|
* @param translate
|
||||||
|
*/
|
||||||
|
public constructor(
|
||||||
|
translate: TranslateService,
|
||||||
|
operator: OperatorService,
|
||||||
|
configService: ConfigService,
|
||||||
|
loginDataService: LoginDataService,
|
||||||
|
constantsService: ConstantsService // Needs to be started, so it can register itself to the WebsocketService
|
||||||
|
) {
|
||||||
|
// manually add the supported languages
|
||||||
|
translate.addLangs(['en', 'de', 'fr']);
|
||||||
|
// this language will be used as a fallback when a translation isn't found in the current language
|
||||||
|
translate.setDefaultLang('en');
|
||||||
|
// get the browsers default language
|
||||||
|
const browserLang = translate.getBrowserLang();
|
||||||
|
// try to use the browser language if it is available. If not, uses english.
|
||||||
|
translate.use(translate.getLangs().includes(browserLang) ? browserLang : 'en');
|
||||||
|
// change default JS functions
|
||||||
|
this.overloadArrayToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to alter the normal Array.toString - function
|
||||||
|
*
|
||||||
|
* Will add a whitespace after a comma and shorten the output to
|
||||||
|
* three strings.
|
||||||
|
*
|
||||||
|
* TODO: There might be a better place for overloading functions than app.component
|
||||||
|
* TODO: Overloading can be extended to more functions.
|
||||||
|
*/
|
||||||
|
private overloadArrayToString(): void {
|
||||||
|
Array.prototype.toString = function(): string {
|
||||||
|
let string = '';
|
||||||
|
const iterations = Math.min(this.length, 3);
|
||||||
|
|
||||||
|
for (let i = 0; i <= iterations; i++) {
|
||||||
|
if (i < iterations) {
|
||||||
|
string += this[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i < iterations - 1) {
|
||||||
|
string += ', ';
|
||||||
|
} else if (i === iterations && this.length > iterations) {
|
||||||
|
string += ', ...';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
61
client/src/app/app.module.ts
Normal file
61
client/src/app/app.module.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
// angular modules
|
||||||
|
import { BrowserModule } from '@angular/platform-browser';
|
||||||
|
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||||
|
import { NgModule, APP_INITIALIZER } from '@angular/core';
|
||||||
|
import { HttpClientModule, HttpClient, HttpClientXsrfModule } from '@angular/common/http';
|
||||||
|
|
||||||
|
// Elementary App Components
|
||||||
|
import { AppRoutingModule } from './app-routing.module';
|
||||||
|
import { AppComponent } from './app.component';
|
||||||
|
import { CoreModule } from './core/core.module';
|
||||||
|
|
||||||
|
// translation module.
|
||||||
|
import { TranslateModule, TranslateLoader } from '@ngx-translate/core';
|
||||||
|
import { PruningTranslationLoader } from './core/pruning-loader';
|
||||||
|
import { LoginModule } from './site/login/login.module';
|
||||||
|
import { AppLoadService } from './core/services/app-load.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For the translation module. Loads a Custom 'translation loader' and provides it as loader.
|
||||||
|
* @param http Just the HttpClient to load stuff
|
||||||
|
*/
|
||||||
|
export function HttpLoaderFactory(http: HttpClient): PruningTranslationLoader {
|
||||||
|
return new PruningTranslationLoader(http);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a function that returns a promis that will be resolved, if all apps are loaded.
|
||||||
|
* @param appLoadService The service that loads the apps.
|
||||||
|
*/
|
||||||
|
export function AppLoaderFactory(appLoadService: AppLoadService): () => Promise<void> {
|
||||||
|
return () => appLoadService.loadApps();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global App Module. Keep it as clean as possible.
|
||||||
|
*/
|
||||||
|
@NgModule({
|
||||||
|
declarations: [AppComponent],
|
||||||
|
imports: [
|
||||||
|
BrowserModule,
|
||||||
|
HttpClientModule,
|
||||||
|
HttpClientXsrfModule.withOptions({
|
||||||
|
cookieName: 'OpenSlidesCsrfToken',
|
||||||
|
headerName: 'X-CSRFToken'
|
||||||
|
}),
|
||||||
|
BrowserAnimationsModule,
|
||||||
|
TranslateModule.forRoot({
|
||||||
|
loader: {
|
||||||
|
provide: TranslateLoader,
|
||||||
|
useFactory: HttpLoaderFactory,
|
||||||
|
deps: [HttpClient]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
AppRoutingModule,
|
||||||
|
CoreModule,
|
||||||
|
LoginModule
|
||||||
|
],
|
||||||
|
providers: [{ provide: APP_INITIALIZER, useFactory: AppLoaderFactory, deps: [AppLoadService], multi: true }],
|
||||||
|
bootstrap: [AppComponent]
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
36
client/src/app/base.component.ts
Normal file
36
client/src/app/base.component.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { Title } from '@angular/platform-browser';
|
||||||
|
import { OpenSlidesComponent } from './openslides.component';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides functionalities that will be used by most components
|
||||||
|
* currently able to set the title with the suffix ' - OpenSlides 3'
|
||||||
|
*
|
||||||
|
* A BaseComponent is an OpenSlides Component.
|
||||||
|
* Components in the 'Side'- or 'projector' Folder are BaseComponents
|
||||||
|
*/
|
||||||
|
export abstract class BaseComponent extends OpenSlidesComponent {
|
||||||
|
/**
|
||||||
|
* To manipulate the browser title bar, adds the Suffix "OpenSlides 3"
|
||||||
|
*
|
||||||
|
* Might be a config variable later at some point
|
||||||
|
*/
|
||||||
|
private titleSuffix = ' - OpenSlides 3';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Child constructor that implements the titleServices and calls Super from OpenSlidesComponent
|
||||||
|
*/
|
||||||
|
public constructor(protected titleService?: Title, protected translate?: TranslateService) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the title in web browser using angulars TitleService
|
||||||
|
* @param prefix The title prefix. Should be translated here.
|
||||||
|
* TODO Might translate the prefix here?
|
||||||
|
*/
|
||||||
|
public setTitle(prefix: string): void {
|
||||||
|
const translatedPrefix = this.translate.instant(prefix);
|
||||||
|
this.titleService.setTitle(translatedPrefix + this.titleSuffix);
|
||||||
|
}
|
||||||
|
}
|
13
client/src/app/core/core.module.spec.ts
Normal file
13
client/src/app/core/core.module.spec.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { CoreModule } from './core.module';
|
||||||
|
|
||||||
|
describe('CoreModule', () => {
|
||||||
|
let coreModule: CoreModule;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
coreModule = new CoreModule(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create an instance', () => {
|
||||||
|
expect(coreModule).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
52
client/src/app/core/core.module.ts
Normal file
52
client/src/app/core/core.module.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { NgModule, Optional, SkipSelf } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { Title } from '@angular/platform-browser';
|
||||||
|
import { HTTP_INTERCEPTORS } from '@angular/common/http';
|
||||||
|
|
||||||
|
// Core Services, Directives
|
||||||
|
import { AuthGuard } from './services/auth-guard.service';
|
||||||
|
import { AuthService } from './services/auth.service';
|
||||||
|
import { AutoupdateService } from './services/autoupdate.service';
|
||||||
|
import { DataStoreService } from './services/data-store.service';
|
||||||
|
import { OperatorService } from './services/operator.service';
|
||||||
|
import { WebsocketService } from './services/websocket.service';
|
||||||
|
import { AddHeaderInterceptor } from './http-interceptor';
|
||||||
|
import { DataSendService } from './services/data-send.service';
|
||||||
|
import { ViewportService } from './services/viewport.service';
|
||||||
|
import { PromptDialogComponent } from '../shared/components/prompt-dialog/prompt-dialog.component';
|
||||||
|
|
||||||
|
/** Global Core Module. Contains all global (singleton) services
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
@NgModule({
|
||||||
|
imports: [CommonModule],
|
||||||
|
providers: [
|
||||||
|
Title,
|
||||||
|
AuthGuard,
|
||||||
|
AuthService,
|
||||||
|
AutoupdateService,
|
||||||
|
DataStoreService,
|
||||||
|
DataSendService,
|
||||||
|
OperatorService,
|
||||||
|
ViewportService,
|
||||||
|
WebsocketService,
|
||||||
|
{
|
||||||
|
provide: HTTP_INTERCEPTORS,
|
||||||
|
useClass: AddHeaderInterceptor,
|
||||||
|
multi: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
entryComponents: [PromptDialogComponent]
|
||||||
|
})
|
||||||
|
export class CoreModule {
|
||||||
|
/** make sure CoreModule is imported only by one NgModule, the AppModule */
|
||||||
|
public constructor(
|
||||||
|
@Optional()
|
||||||
|
@SkipSelf()
|
||||||
|
parentModule: CoreModule
|
||||||
|
) {
|
||||||
|
if (parentModule) {
|
||||||
|
throw new Error('CoreModule is already loaded. Import only in AppModule');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
24
client/src/app/core/http-interceptor.ts
Normal file
24
client/src/app/core/http-interceptor.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { HttpEvent, HttpInterceptor, HttpHandler, HttpRequest } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interceptor class for HTTP requests. Replaces all 'httpOptions' in all http.get or http.post requests.
|
||||||
|
*
|
||||||
|
* Should not need further adjustment.
|
||||||
|
*/
|
||||||
|
export class AddHeaderInterceptor implements HttpInterceptor {
|
||||||
|
/**
|
||||||
|
* Normal HttpInterceptor usage
|
||||||
|
*
|
||||||
|
* @param req Will clone the request and intercept it with our desired headers
|
||||||
|
* @param next HttpHandler will catch the response and forwards it to the original instance
|
||||||
|
*/
|
||||||
|
public intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
||||||
|
const clonedRequest = req.clone({
|
||||||
|
withCredentials: true,
|
||||||
|
headers: req.headers.set('Content-Type', 'application/json')
|
||||||
|
});
|
||||||
|
|
||||||
|
return next.handle(clonedRequest);
|
||||||
|
}
|
||||||
|
}
|
55
client/src/app/core/pruning-loader.ts
Normal file
55
client/src/app/core/pruning-loader.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { TranslateLoader } from '@ngx-translate/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { map } from 'rxjs/operators/';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translation loader that replaces empty strings with nothing.
|
||||||
|
*
|
||||||
|
* ngx-translate-extract writes empty strings into json files.
|
||||||
|
* The problem is that these empty strings don't trigger
|
||||||
|
* the MissingTranslationHandler - they are simply empty strings...
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export class PruningTranslationLoader implements TranslateLoader {
|
||||||
|
/**
|
||||||
|
* Constructor to load the HttpClient
|
||||||
|
*
|
||||||
|
* @param http httpClient to load the translation files.
|
||||||
|
* @param prefix Path to the language files. Can be adjusted of needed
|
||||||
|
* @param suffix Suffix of the translation files. Usually '.json'.
|
||||||
|
*/
|
||||||
|
public constructor(
|
||||||
|
private http: HttpClient,
|
||||||
|
private prefix: string = '/assets/i18n/',
|
||||||
|
private suffix: string = '.json'
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a language file, stores the content, give it to the process function.
|
||||||
|
* @param lang language string (en, fr, de, ...)
|
||||||
|
*/
|
||||||
|
public getTranslation(lang: string): any {
|
||||||
|
return this.http.get(`${this.prefix}${lang}${this.suffix}`).pipe(map((res: Object) => this.process(res)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prevent to display empty strings as a translation.
|
||||||
|
* Falls back to the default language or simply copy the content of the key.
|
||||||
|
* @param any the content of any language file.
|
||||||
|
*/
|
||||||
|
private process(object: any): any {
|
||||||
|
const newObject = {};
|
||||||
|
for (const key in object) {
|
||||||
|
if (object.hasOwnProperty(key)) {
|
||||||
|
if (typeof object[key] === 'object') {
|
||||||
|
newObject[key] = this.process(object[key]);
|
||||||
|
} else if (typeof object[key] === 'string' && object[key] === '') {
|
||||||
|
// do not copy empty strings
|
||||||
|
} else {
|
||||||
|
newObject[key] = object[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newObject;
|
||||||
|
}
|
||||||
|
}
|
15
client/src/app/core/services/app-load.service.spec.ts
Normal file
15
client/src/app/core/services/app-load.service.spec.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { TestBed, inject } from '@angular/core/testing';
|
||||||
|
import { AppLoadService } from './app-load.service';
|
||||||
|
import { E2EImportsModule } from '../../../e2e-imports.module';
|
||||||
|
|
||||||
|
describe('AppLoadService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [E2EImportsModule],
|
||||||
|
providers: [AppLoadService]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('should be created', inject([AppLoadService], (service: AppLoadService) => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
}));
|
||||||
|
});
|
59
client/src/app/core/services/app-load.service.ts
Normal file
59
client/src/app/core/services/app-load.service.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { plugins } from '../../../plugins';
|
||||||
|
import { CommonAppConfig } from '../../site/common/common.config';
|
||||||
|
import { ModelConstructor, BaseModel } from '../../shared/models/base/base-model';
|
||||||
|
import { AppConfig } from '../../site/base/app-config';
|
||||||
|
import { CollectionStringModelMapperService } from './collectionStringModelMapper.service';
|
||||||
|
import { MediafileAppConfig } from '../../site/mediafiles/mediafile.config';
|
||||||
|
import { MotionsAppConfig } from '../../site/motions/motions.config';
|
||||||
|
import { ConfigAppConfig } from '../../site/config/config.config';
|
||||||
|
import { AgendaAppConfig } from '../../site/agenda/agenda.config';
|
||||||
|
import { AssignmentsAppConfig } from '../../site/assignments/assignments.config';
|
||||||
|
import { UsersAppConfig } from '../../site/users/users.config';
|
||||||
|
import { MainMenuService } from './main-menu.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list of all app configurations of all delivered apps.
|
||||||
|
*/
|
||||||
|
const appConfigs: AppConfig[] = [
|
||||||
|
CommonAppConfig,
|
||||||
|
ConfigAppConfig,
|
||||||
|
AgendaAppConfig,
|
||||||
|
AssignmentsAppConfig,
|
||||||
|
MotionsAppConfig,
|
||||||
|
MediafileAppConfig,
|
||||||
|
UsersAppConfig
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles all incoming and outgoing notify messages via {@link WebsocketService}.
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class AppLoadService {
|
||||||
|
public constructor(
|
||||||
|
private modelMapper: CollectionStringModelMapperService,
|
||||||
|
private mainMenuService: MainMenuService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async loadApps(): Promise<void> {
|
||||||
|
if (plugins.length) {
|
||||||
|
console.log('plugins: ', plugins);
|
||||||
|
}
|
||||||
|
/*for (const pluginName of plugins) {
|
||||||
|
const plugin = await import('../../../../../plugins/' + pluginName + '/' + pluginName);
|
||||||
|
plugin.main();
|
||||||
|
}*/
|
||||||
|
appConfigs.forEach((config: AppConfig) => {
|
||||||
|
if (config.models) {
|
||||||
|
config.models.forEach(entry => {
|
||||||
|
this.modelMapper.registerCollectionElement(entry.collectionString, entry.model);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (config.mainMenuEntries) {
|
||||||
|
this.mainMenuService.registerEntries(config.mainMenuEntries);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
48
client/src/app/core/services/auth-guard.service.ts
Normal file
48
client/src/app/core/services/auth-guard.service.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, CanActivateChild } from '@angular/router';
|
||||||
|
|
||||||
|
import { OperatorService } from './operator.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classical Auth-Guard. Checks if the user has to correct permissions to enter a page, and forwards to login if not.
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class AuthGuard implements CanActivate, CanActivateChild {
|
||||||
|
/**
|
||||||
|
* @param operator
|
||||||
|
*/
|
||||||
|
public constructor(private operator: OperatorService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks of the operator has the required permission to see the state.
|
||||||
|
*
|
||||||
|
* One can set extra data to the state with `data: {basePerm: '<perm>'}` or
|
||||||
|
* `data: {basePerm: ['<perm1>', '<perm2>']}` to lock the access to users
|
||||||
|
* only with the given permission(s).
|
||||||
|
*
|
||||||
|
* @param route required by `canActivate()`
|
||||||
|
* @param state the state (URL) that the user want to access
|
||||||
|
*/
|
||||||
|
public canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean {
|
||||||
|
const basePerm: string | string[] = route.data.basePerm;
|
||||||
|
|
||||||
|
if (!basePerm) {
|
||||||
|
return true;
|
||||||
|
} else if (basePerm instanceof Array) {
|
||||||
|
return this.operator.hasPerms(...basePerm);
|
||||||
|
} else {
|
||||||
|
return this.operator.hasPerms(basePerm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls {@method canActivate}. Should have the same logic.
|
||||||
|
* @param route
|
||||||
|
* @param state
|
||||||
|
*/
|
||||||
|
public canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean {
|
||||||
|
return this.canActivate(route, state);
|
||||||
|
}
|
||||||
|
}
|
17
client/src/app/core/services/auth.service.spec.ts
Normal file
17
client/src/app/core/services/auth.service.spec.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { TestBed, inject } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { E2EImportsModule } from '../../../e2e-imports.module';
|
||||||
|
|
||||||
|
describe('ConfigService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [E2EImportsModule],
|
||||||
|
providers: [AuthService]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', inject([AuthService], (service: AuthService) => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
}));
|
||||||
|
});
|
77
client/src/app/core/services/auth.service.ts
Normal file
77
client/src/app/core/services/auth.service.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { catchError, tap } from 'rxjs/operators';
|
||||||
|
|
||||||
|
import { OperatorService } from 'app/core/services/operator.service';
|
||||||
|
import { OpenSlidesComponent } from '../../openslides.component';
|
||||||
|
import { environment } from 'environments/environment';
|
||||||
|
import { User } from '../../shared/models/users/user';
|
||||||
|
import { OpenSlidesService } from './openslides.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The data returned by a post request to the login route.
|
||||||
|
*/
|
||||||
|
interface LoginResponse {
|
||||||
|
user_id: number;
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticates an OpenSlides user with username and password
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class AuthService extends OpenSlidesComponent {
|
||||||
|
/**
|
||||||
|
* Initializes the httpClient and the {@link OperatorService}.
|
||||||
|
*
|
||||||
|
* Calls `super()` from the parent class.
|
||||||
|
* @param http HttpClient
|
||||||
|
* @param operator who is using OpenSlides
|
||||||
|
*/
|
||||||
|
public constructor(
|
||||||
|
private http: HttpClient,
|
||||||
|
private operator: OperatorService,
|
||||||
|
private OpenSlides: OpenSlidesService
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to log in a user.
|
||||||
|
*
|
||||||
|
* Returns an observable 'user' with the correct login information or an error.
|
||||||
|
* The user will then be stored in the {@link OperatorService},
|
||||||
|
* errors will be forwarded to the parents error function.
|
||||||
|
*
|
||||||
|
* @param username
|
||||||
|
* @param password
|
||||||
|
*/
|
||||||
|
public login(username: string, password: string): Observable<LoginResponse> {
|
||||||
|
const user = {
|
||||||
|
username: username,
|
||||||
|
password: password
|
||||||
|
};
|
||||||
|
return this.http.post<LoginResponse>(environment.urlPrefix + '/users/login/', user).pipe(
|
||||||
|
tap((response: LoginResponse) => {
|
||||||
|
this.operator.user = new User(response.user);
|
||||||
|
}),
|
||||||
|
catchError(this.handleError())
|
||||||
|
) as Observable<LoginResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout function for both the client and the server.
|
||||||
|
*
|
||||||
|
* Will clear the current {@link OperatorService} and
|
||||||
|
* send a `post`-request to `/apps/users/logout/'`
|
||||||
|
*/
|
||||||
|
public logout(): void {
|
||||||
|
this.operator.user = null;
|
||||||
|
this.http.post<any>(environment.urlPrefix + '/users/logout/', {}).subscribe(() => {
|
||||||
|
this.OpenSlides.reboot();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
17
client/src/app/core/services/autoupdate.service.spec.ts
Normal file
17
client/src/app/core/services/autoupdate.service.spec.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { TestBed, inject } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { AutoupdateService } from './autoupdate.service';
|
||||||
|
import { E2EImportsModule } from '../../../e2e-imports.module';
|
||||||
|
|
||||||
|
describe('AutoupdateService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [E2EImportsModule],
|
||||||
|
providers: [AutoupdateService]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', inject([AutoupdateService], (service: AutoupdateService) => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
}));
|
||||||
|
});
|
88
client/src/app/core/services/autoupdate.service.ts
Normal file
88
client/src/app/core/services/autoupdate.service.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
import { OpenSlidesComponent } from 'app/openslides.component';
|
||||||
|
import { WebsocketService } from './websocket.service';
|
||||||
|
|
||||||
|
import { CollectionStringModelMapperService } from './collectionStringModelMapper.service';
|
||||||
|
import { DataStoreService } from './data-store.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the initial update and automatic updates using the {@link WebsocketService}
|
||||||
|
* Incoming objects, usually BaseModels, will be saved in the dataStore (`this.DS`)
|
||||||
|
* This service usually creates all models
|
||||||
|
*
|
||||||
|
* The dataStore will injected over the parent class: {@link OpenSlidesComponent}.
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class AutoupdateService extends OpenSlidesComponent {
|
||||||
|
/**
|
||||||
|
* Constructor to create the AutoupdateService. Calls the constructor of the parent class.
|
||||||
|
* @param websocketService
|
||||||
|
*/
|
||||||
|
public constructor(
|
||||||
|
websocketService: WebsocketService,
|
||||||
|
private DS: DataStoreService,
|
||||||
|
private modelMapper: CollectionStringModelMapperService
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
websocketService.getOberservable<any>('autoupdate').subscribe(response => {
|
||||||
|
this.storeResponse(response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the answer of incoming data via {@link WebsocketService}.
|
||||||
|
*
|
||||||
|
* Bundles the data per action and collection. THis speeds up the caching in the DataStore.
|
||||||
|
*
|
||||||
|
* Detects the Class of an incomming model, creates a new empty object and assigns
|
||||||
|
* the data to it using the deserialize function.
|
||||||
|
*
|
||||||
|
* Saves models in DataStore.
|
||||||
|
*/
|
||||||
|
public storeResponse(socketResponse: any): void {
|
||||||
|
// Reorganize the autoupdate: groupy by action, then by collection. The final
|
||||||
|
// entries are the single autoupdate objects.
|
||||||
|
const autoupdate = {
|
||||||
|
changed: {},
|
||||||
|
deleted: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reorganize them.
|
||||||
|
socketResponse.forEach(obj => {
|
||||||
|
if (!autoupdate[obj.action][obj.collection]) {
|
||||||
|
autoupdate[obj.action][obj.collection] = [];
|
||||||
|
}
|
||||||
|
autoupdate[obj.action][obj.collection].push(obj);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete the removed objects from the DataStore
|
||||||
|
Object.keys(autoupdate.deleted).forEach(collection => {
|
||||||
|
this.DS.remove(collection, ...autoupdate.deleted[collection].map(_obj => _obj.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add the objects to the DataStore.
|
||||||
|
Object.keys(autoupdate.changed).forEach(collection => {
|
||||||
|
const targetClass = this.modelMapper.getModelConstructor(collection);
|
||||||
|
if (!targetClass) {
|
||||||
|
// TODO: throw an error later..
|
||||||
|
/*throw new Error*/ console.log(`Unregistered resource ${collection}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.DS.add(...autoupdate.changed[collection].map(_obj => new targetClass(_obj.data)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a WebSocket request to the Server with the maxChangeId of the DataStore.
|
||||||
|
* The server should return an autoupdate with all new data.
|
||||||
|
*
|
||||||
|
* TODO: Wait for changeIds to be implemented on the server.
|
||||||
|
*/
|
||||||
|
public requestChanges(): void {
|
||||||
|
console.log('requesting changed objects');
|
||||||
|
// this.websocketService.send('changeIdRequest', this.DS.maxChangeId);
|
||||||
|
}
|
||||||
|
}
|
15
client/src/app/core/services/cache.service.spec.ts
Normal file
15
client/src/app/core/services/cache.service.spec.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { TestBed, inject } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { CacheService } from './cache.service';
|
||||||
|
|
||||||
|
describe('WebsocketService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [CacheService]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', inject([CacheService], (service: CacheService) => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
}));
|
||||||
|
});
|
151
client/src/app/core/services/cache.service.ts
Normal file
151
client/src/app/core/services/cache.service.ts
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { LocalStorage } from '@ngx-pwa/local-storage';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Container objects for the setQueue.
|
||||||
|
*/
|
||||||
|
interface SetContainer {
|
||||||
|
key: string;
|
||||||
|
item: any;
|
||||||
|
callback: (value: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Container objects for the removeQueue.
|
||||||
|
*/
|
||||||
|
interface RemoveContainer {
|
||||||
|
key: string;
|
||||||
|
callback: (value: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides an async API to an key-value store using ngx-pwa which is internally
|
||||||
|
* using IndexedDB or localStorage as a fallback.
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class CacheService {
|
||||||
|
/**
|
||||||
|
* The queue of waiting set requests. Just one request (with the same key, which is
|
||||||
|
* an often case) at the time can be handeled. The SetContainer encapsulates the key,
|
||||||
|
* item and callback.
|
||||||
|
*/
|
||||||
|
private setQueue: SetContainer[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The queue of waiting remove requests. Same reason for the queue es the @name _setQueue.
|
||||||
|
*/
|
||||||
|
private removeQueue: RemoveContainer[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor to create the CacheService. Needs the localStorage service.
|
||||||
|
* @param localStorage
|
||||||
|
*/
|
||||||
|
public constructor(private localStorage: LocalStorage) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the item into the store asynchronously.
|
||||||
|
* @param key
|
||||||
|
* @param item
|
||||||
|
* @param callback An optional callback that is called on success
|
||||||
|
*/
|
||||||
|
public set(key: string, item: any, callback?: (value: boolean) => void): void {
|
||||||
|
if (!callback) {
|
||||||
|
callback = () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put the set request into the queue
|
||||||
|
const queueObj: SetContainer = {
|
||||||
|
key: key,
|
||||||
|
item: item,
|
||||||
|
callback: callback
|
||||||
|
};
|
||||||
|
this.setQueue.unshift(queueObj);
|
||||||
|
|
||||||
|
// If this is the only object, put it into the cache.
|
||||||
|
if (this.setQueue.length === 1) {
|
||||||
|
this.localStorage.setItem(key, item).subscribe(this._setCallback.bind(this), this._error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* gets called, if a set of the first item in the queue was successful.
|
||||||
|
* @param value success
|
||||||
|
*/
|
||||||
|
private _setCallback(success: boolean): void {
|
||||||
|
// Call the callback and remove the object from the queue
|
||||||
|
this.setQueue[0].callback(success);
|
||||||
|
this.setQueue.pop();
|
||||||
|
// If there are objects left, insert the first one into the cache.
|
||||||
|
if (this.setQueue.length > 0) {
|
||||||
|
const queueObj = this.setQueue[0];
|
||||||
|
this.localStorage.setItem(queueObj.key, queueObj.item).subscribe(this._setCallback.bind(this), this._error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get a value from the store. You need to subscribe to the request to retrieve the value.
|
||||||
|
* @param key The key to get the value from
|
||||||
|
*/
|
||||||
|
public get<T>(key: string): Observable<T> {
|
||||||
|
return this.localStorage.getItem<T>(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the key from the store.
|
||||||
|
* @param key The key to remove the value from
|
||||||
|
* @param callback An optional callback that is called on success
|
||||||
|
*/
|
||||||
|
public remove(key: string, callback?: (value: boolean) => void): void {
|
||||||
|
if (!callback) {
|
||||||
|
callback = () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put the remove request into the queue
|
||||||
|
const queueObj: RemoveContainer = {
|
||||||
|
key: key,
|
||||||
|
callback: callback
|
||||||
|
};
|
||||||
|
this.removeQueue.unshift(queueObj);
|
||||||
|
|
||||||
|
// If this is the only object, remove it from the cache.
|
||||||
|
if (this.removeQueue.length === 1) {
|
||||||
|
this.localStorage.removeItem(key).subscribe(this._removeCallback.bind(this), this._error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* gets called, if a remove of the first item in the queue was successfull.
|
||||||
|
* @param value success
|
||||||
|
*/
|
||||||
|
private _removeCallback(success: boolean): void {
|
||||||
|
// Call the callback and remove the object from the queue
|
||||||
|
this.removeQueue[0].callback(success);
|
||||||
|
this.removeQueue.pop();
|
||||||
|
// If there are objects left, remove the first one from the cache.
|
||||||
|
if (this.removeQueue.length > 0) {
|
||||||
|
const queueObj = this.removeQueue[0];
|
||||||
|
this.localStorage.removeItem(queueObj.key).subscribe(this._removeCallback.bind(this), this._error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the whole cache
|
||||||
|
* @param callback An optional callback that is called on success
|
||||||
|
*/
|
||||||
|
public clear(callback?: (value: boolean) => void): void {
|
||||||
|
if (!callback) {
|
||||||
|
callback = () => {};
|
||||||
|
}
|
||||||
|
this.localStorage.clear().subscribe(callback, this._error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First error catching function.
|
||||||
|
*/
|
||||||
|
private _error(): void {
|
||||||
|
console.error('caching error', arguments);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,3 @@
|
|||||||
|
describe('CollectionStringModelMapperService', () => {
|
||||||
|
beforeEach(() => {});
|
||||||
|
});
|
@ -0,0 +1,64 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { ModelConstructor, BaseModel } from '../../shared/models/base/base-model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registeres the mapping of collection strings <--> actual types. Every Model should register itself here.
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class CollectionStringModelMapperService {
|
||||||
|
/**
|
||||||
|
* Mapps collection strings to model constructors. Accessed by {@method registerCollectionElement} and
|
||||||
|
* {@method getCollectionStringType}.
|
||||||
|
*/
|
||||||
|
private static collectionStringsTypeMapping: { [collectionString: string]: ModelConstructor<BaseModel> } = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the collection string of a given ModelConstructor or undefined, if it is not registered.
|
||||||
|
* @param ctor
|
||||||
|
* @deprecated Should inject this service and don't use the static functions.
|
||||||
|
*/
|
||||||
|
public static getCollectionString(ctor: ModelConstructor<BaseModel>): string {
|
||||||
|
return Object.keys(CollectionStringModelMapperService.collectionStringsTypeMapping).find(
|
||||||
|
(collectionString: string) => {
|
||||||
|
return ctor === CollectionStringModelMapperService.collectionStringsTypeMapping[collectionString];
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor to create the NotifyService. Registers itself to the WebsocketService.
|
||||||
|
* @param websocketService
|
||||||
|
*/
|
||||||
|
public constructor() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers the type to the collection string
|
||||||
|
* @param collectionString
|
||||||
|
* @param type
|
||||||
|
*/
|
||||||
|
public registerCollectionElement(collectionString: string, type: ModelConstructor<BaseModel>): void {
|
||||||
|
CollectionStringModelMapperService.collectionStringsTypeMapping[collectionString] = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the constructor of the requested collection or undefined, if it is not registered.
|
||||||
|
* @param collectionString the requested collection
|
||||||
|
*/
|
||||||
|
public getModelConstructor(collectionString: string): ModelConstructor<BaseModel> {
|
||||||
|
return CollectionStringModelMapperService.collectionStringsTypeMapping[collectionString];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the collection string of a given ModelConstructor or undefined, if it is not registered.
|
||||||
|
* @param ctor
|
||||||
|
*/
|
||||||
|
public getCollectionString(ctor: ModelConstructor<BaseModel>): string {
|
||||||
|
return Object.keys(CollectionStringModelMapperService.collectionStringsTypeMapping).find(
|
||||||
|
(collectionString: string) => {
|
||||||
|
return ctor === CollectionStringModelMapperService.collectionStringsTypeMapping[collectionString];
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
15
client/src/app/core/services/config.service.spec.ts
Normal file
15
client/src/app/core/services/config.service.spec.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { TestBed, inject } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { ConfigService } from './config.service';
|
||||||
|
|
||||||
|
describe('ConfigService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [ConfigService]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', inject([ConfigService], (service: ConfigService) => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
}));
|
||||||
|
});
|
76
client/src/app/core/services/config.service.ts
Normal file
76
client/src/app/core/services/config.service.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
import { OpenSlidesComponent } from 'app/openslides.component';
|
||||||
|
import { Observable, BehaviorSubject } from 'rxjs';
|
||||||
|
import { Config } from '../../shared/models/core/config';
|
||||||
|
import { DataStoreService } from './data-store.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for config variables.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* this.configService.get('general_event_name').subscribe(value => {
|
||||||
|
* console.log(value);
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const value = this.configService.instant('general_event_name');
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class ConfigService extends OpenSlidesComponent {
|
||||||
|
/**
|
||||||
|
* Stores a subject per key. Values are published, if the DataStore gets an update.
|
||||||
|
*/
|
||||||
|
private configSubjects: { [key: string]: BehaviorSubject<any> } = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listen for changes of config variables.
|
||||||
|
*/
|
||||||
|
public constructor(private DS: DataStoreService) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.DS.changeObservable.subscribe(data => {
|
||||||
|
// on changes notify the observers for specific keys.
|
||||||
|
if (data instanceof Config && this.configSubjects[data.key]) {
|
||||||
|
this.configSubjects[data.key].next(data.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the constant named by key from the DataStore. If the DataStore isn't up to date or
|
||||||
|
* not filled via autoupdates the results may be wrong/empty. Use this with caution.
|
||||||
|
*
|
||||||
|
* Usefull for synchronos code, e.g. during generation of PDFs, when the DataStore is filled.
|
||||||
|
*
|
||||||
|
* @param key The config value to get from.
|
||||||
|
*/
|
||||||
|
public instant(key: string): any {
|
||||||
|
const values = this.DS.filter<Config>('core/config', value => value.key === key);
|
||||||
|
if (values.length > 1) {
|
||||||
|
throw new Error('More keys found then expected');
|
||||||
|
} else if (values.length === 1) {
|
||||||
|
return values[0].value;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an observer for the config value given by the key.
|
||||||
|
*
|
||||||
|
* @param key The config value to get from.
|
||||||
|
*/
|
||||||
|
public get(key: string): Observable<any> {
|
||||||
|
if (!this.configSubjects[key]) {
|
||||||
|
this.configSubjects[key] = new BehaviorSubject<any>(this.instant(key));
|
||||||
|
}
|
||||||
|
return this.configSubjects[key].asObservable();
|
||||||
|
}
|
||||||
|
}
|
17
client/src/app/core/services/constants.service.spec.ts
Normal file
17
client/src/app/core/services/constants.service.spec.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { TestBed, inject } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { ConstantsService } from './constants.service';
|
||||||
|
import { E2EImportsModule } from '../../../e2e-imports.module';
|
||||||
|
|
||||||
|
describe('ConstantsService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [E2EImportsModule],
|
||||||
|
providers: [ConstantsService]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', inject([ConstantsService], (service: ConstantsService) => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
}));
|
||||||
|
});
|
97
client/src/app/core/services/constants.service.ts
Normal file
97
client/src/app/core/services/constants.service.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
import { OpenSlidesComponent } from 'app/openslides.component';
|
||||||
|
import { WebsocketService } from './websocket.service';
|
||||||
|
import { Observable, of, Subject } from 'rxjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* constants have a key associated with the data.
|
||||||
|
*/
|
||||||
|
interface Constants {
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get constants from the server.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* this.constantsService.get('OpenSlidesSettings').subscribe(constant => {
|
||||||
|
* console.log(constant);
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class ConstantsService extends OpenSlidesComponent {
|
||||||
|
/**
|
||||||
|
* The constants
|
||||||
|
*/
|
||||||
|
private constants: Constants;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flag, if the websocket connection is open.
|
||||||
|
*/
|
||||||
|
private websocketOpen = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flag, if constants are requested, but the server hasn't send them yet.
|
||||||
|
*/
|
||||||
|
private pending = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pending requests will be notified by these subjects, one per key.
|
||||||
|
*/
|
||||||
|
private pendingSubject: { [key: string]: Subject<any> } = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param websocketService
|
||||||
|
*/
|
||||||
|
public constructor(private websocketService: WebsocketService) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
// The hook for recieving constants.
|
||||||
|
websocketService.getOberservable<Constants>('constants').subscribe(constants => {
|
||||||
|
this.constants = constants;
|
||||||
|
if (this.pending) {
|
||||||
|
// send constants to subscribers that await constants.
|
||||||
|
this.pending = false;
|
||||||
|
Object.keys(this.pendingSubject).forEach(key => {
|
||||||
|
this.pendingSubject[key].next(this.constants[key]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// We can request constants, if the websocket connection opens.
|
||||||
|
websocketService.connectEvent.subscribe(() => {
|
||||||
|
if (!this.websocketOpen && this.pending) {
|
||||||
|
this.websocketService.send('constants', {});
|
||||||
|
}
|
||||||
|
this.websocketOpen = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the constant named by key.
|
||||||
|
* @param key The constant to get.
|
||||||
|
*/
|
||||||
|
public get(key: string): Observable<any> {
|
||||||
|
if (this.constants) {
|
||||||
|
return of(this.constants[key]);
|
||||||
|
} else {
|
||||||
|
// we have to request constants.
|
||||||
|
if (!this.pending) {
|
||||||
|
this.pending = true;
|
||||||
|
// if the connection is open, we directly can send the request.
|
||||||
|
if (this.websocketOpen) {
|
||||||
|
this.websocketService.send('constants', {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!this.pendingSubject[key]) {
|
||||||
|
this.pendingSubject[key] = new Subject<any>();
|
||||||
|
}
|
||||||
|
return this.pendingSubject[key].asObservable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
17
client/src/app/core/services/data-send.service.spec.ts
Normal file
17
client/src/app/core/services/data-send.service.spec.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { TestBed, inject } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { DataSendService } from './data-send.service';
|
||||||
|
import { E2EImportsModule } from '../../../e2e-imports.module';
|
||||||
|
|
||||||
|
describe('DataSendService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [E2EImportsModule],
|
||||||
|
providers: [DataSendService]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', inject([DataSendService], (service: DataSendService) => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
}));
|
||||||
|
});
|
89
client/src/app/core/services/data-send.service.ts
Normal file
89
client/src/app/core/services/data-send.service.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { BaseModel } from '../../shared/models/base/base-model';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { tap } from 'rxjs/operators';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send data back to server
|
||||||
|
*
|
||||||
|
* Contrast to dataStore service
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class DataSendService {
|
||||||
|
/**
|
||||||
|
* Construct a DataSendService
|
||||||
|
*
|
||||||
|
* @param http The HTTP Client
|
||||||
|
*/
|
||||||
|
public constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a post request with the model to the server.
|
||||||
|
* Usually for new Models
|
||||||
|
*/
|
||||||
|
public createModel(model: BaseModel): Observable<BaseModel> {
|
||||||
|
return this.http.post<BaseModel>('rest/' + model.collectionString + '/', model).pipe(
|
||||||
|
tap(
|
||||||
|
response => {
|
||||||
|
// TODO: Message, Notify, Etc
|
||||||
|
console.log('New Model added. Response ::\n', response);
|
||||||
|
},
|
||||||
|
error => console.error('createModel has returned an Error:\n', error)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to change a model on the server.
|
||||||
|
*
|
||||||
|
* @param model the base model that is meant to be changed
|
||||||
|
* @param method the required http method. might be put or patch
|
||||||
|
*/
|
||||||
|
public updateModel(model: BaseModel, method: 'put' | 'patch'): Observable<BaseModel> {
|
||||||
|
const restPath = `rest/${model.collectionString}/${model.id}`;
|
||||||
|
let httpMethod;
|
||||||
|
|
||||||
|
if (method === 'patch') {
|
||||||
|
httpMethod = this.http.patch<BaseModel>(restPath, model);
|
||||||
|
} else if (method === 'put') {
|
||||||
|
httpMethod = this.http.put<BaseModel>(restPath, model);
|
||||||
|
}
|
||||||
|
|
||||||
|
return httpMethod.pipe(
|
||||||
|
tap(
|
||||||
|
response => {
|
||||||
|
// TODO: Message, Notify, Etc
|
||||||
|
console.log('Update model. Response ::\n', response);
|
||||||
|
},
|
||||||
|
error => console.error('updateModel has returned an Error:\n', error)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the given model on the server
|
||||||
|
*
|
||||||
|
* @param model the BaseModel that shall be removed
|
||||||
|
* @return Observable of BaseModel
|
||||||
|
*
|
||||||
|
* TODO Not tested
|
||||||
|
*/
|
||||||
|
public delete(model: BaseModel): Observable<BaseModel> {
|
||||||
|
if (model.id) {
|
||||||
|
return this.http.delete<BaseModel>('rest/' + model.collectionString + '/' + model.id).pipe(
|
||||||
|
tap(
|
||||||
|
response => {
|
||||||
|
// TODO: Message, Notify, Etc
|
||||||
|
console.log('the response: ', response);
|
||||||
|
},
|
||||||
|
error => console.error('error during delete: ', error)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error('No model ID to delete');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
11
client/src/app/core/services/data-store.service.spec.ts
Normal file
11
client/src/app/core/services/data-store.service.spec.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { DataStoreService } from './data-store.service';
|
||||||
|
|
||||||
|
describe('DS', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [DataStoreService]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
338
client/src/app/core/services/data-store.service.ts
Normal file
338
client/src/app/core/services/data-store.service.ts
Normal file
@ -0,0 +1,338 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Observable, Subject } from 'rxjs';
|
||||||
|
|
||||||
|
import { BaseModel, ModelConstructor } from '../../shared/models/base/base-model';
|
||||||
|
import { CacheService } from './cache.service';
|
||||||
|
import { CollectionStringModelMapperService } from './collectionStringModelMapper.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents information about a deleted model.
|
||||||
|
*
|
||||||
|
* As the model doesn't exist anymore, just the former id and collection is known.
|
||||||
|
*/
|
||||||
|
export interface DeletedInformation {
|
||||||
|
collection: string;
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* represents a collection on the Django server, uses an ID to access a {@link BaseModel}.
|
||||||
|
*
|
||||||
|
* Part of {@link DataStoreService}
|
||||||
|
*/
|
||||||
|
interface ModelCollection {
|
||||||
|
[id: number]: BaseModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a serialized collection.
|
||||||
|
*/
|
||||||
|
interface JsonCollection {
|
||||||
|
[id: number]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The actual storage that stores collections, accessible by strings.
|
||||||
|
*
|
||||||
|
* {@link DataStoreService}
|
||||||
|
*/
|
||||||
|
interface ModelStorage {
|
||||||
|
[collectionString: string]: ModelCollection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A storage of serialized collection elements.
|
||||||
|
*/
|
||||||
|
interface JsonStorage {
|
||||||
|
[collectionString: string]: JsonCollection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All mighty DataStore that comes with all OpenSlides components.
|
||||||
|
* Use this.DS in an OpenSlides Component to Access the store.
|
||||||
|
* Used by a lot of components, classes and services.
|
||||||
|
* Changes can be observed
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class DataStoreService {
|
||||||
|
private static cachePrefix = 'DS:';
|
||||||
|
|
||||||
|
/** We will store the data twice: One as instances of the actual models in the _store
|
||||||
|
* and one serialized version in the _serializedStore for the cache. Both should be updated in
|
||||||
|
* all cases equal!
|
||||||
|
*/
|
||||||
|
private modelStore: ModelStorage = {};
|
||||||
|
private JsonStore: JsonStorage = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observable subject for changed models in the datastore.
|
||||||
|
*/
|
||||||
|
private changedSubject: Subject<BaseModel> = new Subject<BaseModel>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observe the datastore for changes.
|
||||||
|
*
|
||||||
|
* @return an observable for changed models
|
||||||
|
*/
|
||||||
|
public get changeObservable(): Observable<BaseModel> {
|
||||||
|
return this.changedSubject.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observable subject for changed models in the datastore.
|
||||||
|
*/
|
||||||
|
private deletedSubject: Subject<DeletedInformation> = new Subject<DeletedInformation>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observe the datastore for deletions.
|
||||||
|
*
|
||||||
|
* @return an observable for deleted objects.
|
||||||
|
*/
|
||||||
|
public get deletedObservable(): Observable<DeletedInformation> {
|
||||||
|
return this.deletedSubject.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The maximal change id from this DataStore.
|
||||||
|
*/
|
||||||
|
private _maxChangeId = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* returns the maxChangeId of the DataStore.
|
||||||
|
*/
|
||||||
|
public get maxChangeId(): number {
|
||||||
|
return this._maxChangeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty constructor for dataStore
|
||||||
|
* @param cacheService use CacheService to cache the DataStore.
|
||||||
|
*/
|
||||||
|
public constructor(private cacheService: CacheService, private modelMapper: CollectionStringModelMapperService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the DataStore from cache and instantiate all models out of the serialized version.
|
||||||
|
*/
|
||||||
|
public initFromCache(): Promise<number> {
|
||||||
|
// This promise will be resolved with the maximal change id of the cache.
|
||||||
|
return new Promise<number>(resolve => {
|
||||||
|
this.cacheService.get<JsonStorage>(DataStoreService.cachePrefix + 'DS').subscribe((store: JsonStorage) => {
|
||||||
|
if (store != null) {
|
||||||
|
// There is a store. Deserialize it
|
||||||
|
this.JsonStore = store;
|
||||||
|
this.modelStore = this.deserializeJsonStore(this.JsonStore);
|
||||||
|
// Get the maxChangeId from the cache
|
||||||
|
this.cacheService
|
||||||
|
.get<number>(DataStoreService.cachePrefix + 'maxChangeId')
|
||||||
|
.subscribe((maxChangeId: number) => {
|
||||||
|
if (maxChangeId == null) {
|
||||||
|
maxChangeId = 0;
|
||||||
|
}
|
||||||
|
this._maxChangeId = maxChangeId;
|
||||||
|
resolve(maxChangeId);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// No store here, so get all data from the server.
|
||||||
|
resolve(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deserialze the given serializedStorage and returns a Storage.
|
||||||
|
*/
|
||||||
|
private deserializeJsonStore(serializedStore: JsonStorage): ModelStorage {
|
||||||
|
const storage: ModelStorage = {};
|
||||||
|
Object.keys(serializedStore).forEach(collectionString => {
|
||||||
|
storage[collectionString] = {} as ModelCollection;
|
||||||
|
const target = this.modelMapper.getModelConstructor(collectionString);
|
||||||
|
if (target) {
|
||||||
|
Object.keys(serializedStore[collectionString]).forEach(id => {
|
||||||
|
const data = JSON.parse(serializedStore[collectionString][id]);
|
||||||
|
storage[collectionString][id] = new target(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the complete DataStore and Cache.
|
||||||
|
* @param callback
|
||||||
|
*/
|
||||||
|
public clear(callback?: (value: boolean) => void): void {
|
||||||
|
this.modelStore = {};
|
||||||
|
this.JsonStore = {};
|
||||||
|
this._maxChangeId = 0;
|
||||||
|
this.cacheService.remove(DataStoreService.cachePrefix + 'DS', () => {
|
||||||
|
this.cacheService.remove(DataStoreService.cachePrefix + 'maxChangeId', callback);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCollectionString<T extends BaseModel<T>>(collectionType: ModelConstructor<T> | string): string {
|
||||||
|
if (typeof collectionType === 'string') {
|
||||||
|
return collectionType;
|
||||||
|
} else {
|
||||||
|
return this.modelMapper.getCollectionString(collectionType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read one model based on the collection and id from the DataStore.
|
||||||
|
*
|
||||||
|
* @param collectionType The desired BaseModel or collectionString to be read from the dataStore
|
||||||
|
* @param ids One ID of the BaseModel
|
||||||
|
* @return The given BaseModel-subclass instance
|
||||||
|
* @example: this.DS.get(User, 1)
|
||||||
|
* @example: this.DS.get<Countdown>('core/countdown', 2)
|
||||||
|
*/
|
||||||
|
public get<T extends BaseModel<T>>(collectionType: ModelConstructor<T> | string, id: number): T {
|
||||||
|
const collectionString = this.getCollectionString<T>(collectionType);
|
||||||
|
|
||||||
|
const collection: ModelCollection = this.modelStore[collectionString];
|
||||||
|
if (!collection) {
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
return collection[id] as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read multiple ID's from dataStore.
|
||||||
|
*
|
||||||
|
* @param collectionType The desired BaseModel or collectionString to be read from the dataStore
|
||||||
|
* @param ids Multiple IDs as a list of IDs of BaseModel
|
||||||
|
* @return The BaseModel-list corresponding to the given ID(s)
|
||||||
|
* @example: this.DS.getMany(User, [1,2,3,4,5])
|
||||||
|
* @example: this.DS.getMany<User>('users/user', [1,2,3,4,5])
|
||||||
|
*/
|
||||||
|
public getMany<T extends BaseModel<T>>(collectionType: ModelConstructor<T> | string, ids: number[]): T[] {
|
||||||
|
const collectionString = this.getCollectionString<T>(collectionType);
|
||||||
|
|
||||||
|
const collection: ModelCollection = this.modelStore[collectionString];
|
||||||
|
if (!collection) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const models = ids
|
||||||
|
.map(id => {
|
||||||
|
return collection[id];
|
||||||
|
})
|
||||||
|
.filter(model => !!model); // remove non valid models.
|
||||||
|
return models as T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all models of the given collection from the DataStore.
|
||||||
|
*
|
||||||
|
* @param collectionType The desired BaseModel or collectionString to be read from the dataStore
|
||||||
|
* @return The BaseModel-list of all instances of T
|
||||||
|
* @example: this.DS.getAll(User)
|
||||||
|
* @example: this.DS.getAll<User>('users/user')
|
||||||
|
*/
|
||||||
|
public getAll<T extends BaseModel<T>>(collectionType: ModelConstructor<T> | string): T[] {
|
||||||
|
const collectionString = this.getCollectionString<T>(collectionType);
|
||||||
|
|
||||||
|
const collection: ModelCollection = this.modelStore[collectionString];
|
||||||
|
if (!collection) {
|
||||||
|
return [];
|
||||||
|
} else {
|
||||||
|
return Object.values(collection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters the dataStore by type.
|
||||||
|
*
|
||||||
|
* @param collectionType The desired BaseModel type to be read from the dataStore
|
||||||
|
* @param callback a filter function
|
||||||
|
* @return The BaseModel-list corresponding to the filter function
|
||||||
|
* @example this.DS.filter<User>(User, myUser => myUser.first_name === "Max")
|
||||||
|
*/
|
||||||
|
public filter<T extends BaseModel<T>>(
|
||||||
|
collectionType: ModelConstructor<T> | string,
|
||||||
|
callback: (model: T) => boolean
|
||||||
|
): T[] {
|
||||||
|
return this.getAll<T>(collectionType).filter(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add one or multiple models to dataStore.
|
||||||
|
*
|
||||||
|
* @param ...models The model(s) that shall be add use spread operator ("...")
|
||||||
|
* @example this.DS.add(new User(1))
|
||||||
|
* @example this.DS.add((new User(2), new User(3)))
|
||||||
|
* @example this.DS.add(...arrayWithUsers)
|
||||||
|
*/
|
||||||
|
public add(...models: BaseModel[]): void {
|
||||||
|
const maxChangeId = 0;
|
||||||
|
models.forEach(model => {
|
||||||
|
const collectionString = model.collectionString;
|
||||||
|
if (!model.id) {
|
||||||
|
throw new Error('The model must have an id!');
|
||||||
|
} else if (collectionString === 'invalid-collection-string') {
|
||||||
|
throw new Error('Cannot save a BaseModel');
|
||||||
|
}
|
||||||
|
if (this.modelStore[collectionString] === undefined) {
|
||||||
|
this.modelStore[collectionString] = {};
|
||||||
|
}
|
||||||
|
this.modelStore[collectionString][model.id] = model;
|
||||||
|
|
||||||
|
if (this.JsonStore[collectionString] === undefined) {
|
||||||
|
this.JsonStore[collectionString] = {};
|
||||||
|
}
|
||||||
|
this.JsonStore[collectionString][model.id] = JSON.stringify(model);
|
||||||
|
// if (model.changeId > maxChangeId) {maxChangeId = model.maxChangeId;}
|
||||||
|
this.changedSubject.next(model);
|
||||||
|
});
|
||||||
|
this.storeToCache(maxChangeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* removes one or multiple models from dataStore.
|
||||||
|
*
|
||||||
|
* @param Type The desired BaseModel type to be read from the dataStore
|
||||||
|
* @param ...ids An or multiple IDs or a list of IDs of BaseModels. use spread operator ("...") for arrays
|
||||||
|
* @example this.DS.remove('users/user', myUser.id, 3, 4)
|
||||||
|
*/
|
||||||
|
public remove(collectionString: string, ...ids: number[]): void {
|
||||||
|
const maxChangeId = 0;
|
||||||
|
ids.forEach(id => {
|
||||||
|
if (this.modelStore[collectionString]) {
|
||||||
|
// get changeId from store
|
||||||
|
// if (model.changeId > maxChangeId) {maxChangeId = model.maxChangeId;}
|
||||||
|
delete this.modelStore[collectionString][id];
|
||||||
|
}
|
||||||
|
if (this.JsonStore[collectionString]) {
|
||||||
|
delete this.JsonStore[collectionString][id];
|
||||||
|
}
|
||||||
|
this.deletedSubject.next({
|
||||||
|
collection: collectionString,
|
||||||
|
id: id
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.storeToCache(maxChangeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the cache by inserting the serialized DataStore. Also changes the chageId, if it's larger
|
||||||
|
* @param maxChangeId
|
||||||
|
*/
|
||||||
|
private storeToCache(maxChangeId: number): void {
|
||||||
|
this.cacheService.set(DataStoreService.cachePrefix + 'DS', this.JsonStore);
|
||||||
|
if (maxChangeId > this._maxChangeId) {
|
||||||
|
this._maxChangeId = maxChangeId;
|
||||||
|
this.cacheService.set(DataStoreService.cachePrefix + 'maxChangeId', maxChangeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints the whole dataStore
|
||||||
|
* @deprecated Shouldn't be used, will be removed later
|
||||||
|
*/
|
||||||
|
public printWhole(): void {
|
||||||
|
console.log('Everything in DataStore: ', this.modelStore);
|
||||||
|
}
|
||||||
|
}
|
15
client/src/app/core/services/login-data.service.spec.ts
Normal file
15
client/src/app/core/services/login-data.service.spec.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { TestBed, inject } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { LoginDataService } from './login-data.service';
|
||||||
|
|
||||||
|
describe('LoginDataService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [LoginDataService]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', inject([LoginDataService], (service: LoginDataService) => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
}));
|
||||||
|
});
|
70
client/src/app/core/services/login-data.service.ts
Normal file
70
client/src/app/core/services/login-data.service.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
import { OpenSlidesComponent } from 'app/openslides.component';
|
||||||
|
import { ConfigService } from './config.service';
|
||||||
|
import { BehaviorSubject, Observable } from 'rxjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This service holds the privacy policy and the legal notice, so they are available
|
||||||
|
* even if the user is not logged in.
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class LoginDataService extends OpenSlidesComponent {
|
||||||
|
/**
|
||||||
|
* Holds the privacy policy
|
||||||
|
*/
|
||||||
|
private _privacy_policy = new BehaviorSubject<string>('');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an observable for the privacy policy
|
||||||
|
*/
|
||||||
|
public get privacy_policy(): Observable<string> {
|
||||||
|
return this._privacy_policy.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the legal notice
|
||||||
|
*/
|
||||||
|
private _legal_notice = new BehaviorSubject<string>('');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an observable for the legal notice
|
||||||
|
*/
|
||||||
|
public get legal_notice(): Observable<string> {
|
||||||
|
return this._legal_notice.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs this service. The config service is needed to update the privacy
|
||||||
|
* policy and legal notice, when their config values change.
|
||||||
|
* @param configService
|
||||||
|
*/
|
||||||
|
public constructor(private configService: ConfigService) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.configService.get('general_event_privacy_policy').subscribe(value => {
|
||||||
|
this.setPrivacyPolicy(value);
|
||||||
|
});
|
||||||
|
this.configService.get('general_event_legal_notice').subscribe(value => {
|
||||||
|
this.setLegalNotice(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setter for the privacy policy
|
||||||
|
* @param privacyPolicy The new privacy policy to set
|
||||||
|
*/
|
||||||
|
public setPrivacyPolicy(privacyPolicy: string): void {
|
||||||
|
this._privacy_policy.next(privacyPolicy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setter for the legal notice
|
||||||
|
* @param legalNotice The new legal notice to set
|
||||||
|
*/
|
||||||
|
public setLegalNotice(legalNotice: string): void {
|
||||||
|
this._legal_notice.next(legalNotice);
|
||||||
|
}
|
||||||
|
}
|
15
client/src/app/core/services/main-menu.service.spec.ts
Normal file
15
client/src/app/core/services/main-menu.service.spec.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { TestBed, inject } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { MainMenuService } from './main-menu.service';
|
||||||
|
|
||||||
|
describe('MainMenuService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [MainMenuService]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', inject([MainMenuService], (service: MainMenuService) => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
}));
|
||||||
|
});
|
61
client/src/app/core/services/main-menu.service.ts
Normal file
61
client/src/app/core/services/main-menu.service.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This represents one entry in the main menu
|
||||||
|
*/
|
||||||
|
export interface MainMenuEntry {
|
||||||
|
/**
|
||||||
|
* The route for the router to navigate to on click.
|
||||||
|
*/
|
||||||
|
route: string;
|
||||||
|
/**
|
||||||
|
* The display string to be shown.
|
||||||
|
*/
|
||||||
|
displayName: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The font awesom icon to display.
|
||||||
|
*/
|
||||||
|
icon: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For sorting the entries.
|
||||||
|
*/
|
||||||
|
weight: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The permission to see the entry.
|
||||||
|
*/
|
||||||
|
permission: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collects main menu entries and provides them to the main menu component.
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class MainMenuService {
|
||||||
|
/**
|
||||||
|
* A list of sorted entries.
|
||||||
|
*/
|
||||||
|
private _entries: MainMenuEntry[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make the entries public.
|
||||||
|
*/
|
||||||
|
public get entries(): MainMenuEntry[] {
|
||||||
|
return this._entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public constructor() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds entries to the mainmenu.
|
||||||
|
* @param entries The entries to add
|
||||||
|
*/
|
||||||
|
public registerEntries(entries: MainMenuEntry[]): void {
|
||||||
|
this._entries.push(...entries);
|
||||||
|
this._entries = this._entries.sort((a, b) => a.weight - b.weight);
|
||||||
|
}
|
||||||
|
}
|
17
client/src/app/core/services/notify.service.spec.ts
Normal file
17
client/src/app/core/services/notify.service.spec.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { TestBed, inject } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { NotifyService } from './notify.service';
|
||||||
|
import { E2EImportsModule } from '../../../e2e-imports.module';
|
||||||
|
|
||||||
|
describe('NotifyService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [E2EImportsModule],
|
||||||
|
providers: [NotifyService]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', inject([NotifyService], (service: NotifyService) => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
}));
|
||||||
|
});
|
42
client/src/app/core/services/notify.service.ts
Normal file
42
client/src/app/core/services/notify.service.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
import { OpenSlidesComponent } from 'app/openslides.component';
|
||||||
|
import { WebsocketService } from './websocket.service';
|
||||||
|
|
||||||
|
interface NotifyFormat {
|
||||||
|
id: number; // Dummy
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles all incoming and outgoing notify messages via {@link WebsocketService}.
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class NotifyService extends OpenSlidesComponent {
|
||||||
|
/**
|
||||||
|
* Constructor to create the NotifyService. Registers itself to the WebsocketService.
|
||||||
|
* @param websocketService
|
||||||
|
*/
|
||||||
|
public constructor(private websocketService: WebsocketService) {
|
||||||
|
super();
|
||||||
|
websocketService.getOberservable<any>('notify').subscribe(notify => {
|
||||||
|
this.receive(notify);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implement this
|
||||||
|
private receive(notify: NotifyFormat): void {
|
||||||
|
console.log('recv', notify);
|
||||||
|
// TODO: Use a Subject, so one can subscribe and get notifies.
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Make this api better: e.g. send(data, users?, projectors?, channel?, ...)
|
||||||
|
/**
|
||||||
|
* Sents a notify object to the server
|
||||||
|
* @param notify the notify objects
|
||||||
|
*/
|
||||||
|
public send(notify: NotifyFormat): void {
|
||||||
|
this.websocketService.send('notify', notify);
|
||||||
|
}
|
||||||
|
}
|
17
client/src/app/core/services/openslides.service.spec.ts
Normal file
17
client/src/app/core/services/openslides.service.spec.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { TestBed, inject } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { OpenSlidesService } from './openslides.service';
|
||||||
|
import { E2EImportsModule } from '../../../e2e-imports.module';
|
||||||
|
|
||||||
|
describe('OpenSlidesService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [E2EImportsModule],
|
||||||
|
providers: [OpenSlidesService]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', inject([OpenSlidesService], (service: OpenSlidesService) => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
}));
|
||||||
|
});
|
139
client/src/app/core/services/openslides.service.ts
Normal file
139
client/src/app/core/services/openslides.service.ts
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
|
||||||
|
import { OpenSlidesComponent } from 'app/openslides.component';
|
||||||
|
import { WebsocketService } from './websocket.service';
|
||||||
|
import { OperatorService } from './operator.service';
|
||||||
|
import { CacheService } from './cache.service';
|
||||||
|
import { AutoupdateService } from './autoupdate.service';
|
||||||
|
import { DataStoreService } from './data-store.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the bootup/showdown of this application.
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class OpenSlidesService extends OpenSlidesComponent {
|
||||||
|
/**
|
||||||
|
* if the user tries to access a certain URL without being authenticated, the URL will be stored here
|
||||||
|
*/
|
||||||
|
public redirectUrl: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor to create the NotifyService. Registers itself to the WebsocketService.
|
||||||
|
* @param cacheService
|
||||||
|
* @param operator
|
||||||
|
* @param websocketService
|
||||||
|
* @param router
|
||||||
|
* @param autoupdateService
|
||||||
|
*/
|
||||||
|
public constructor(
|
||||||
|
private cacheService: CacheService,
|
||||||
|
private operator: OperatorService,
|
||||||
|
private websocketService: WebsocketService,
|
||||||
|
private router: Router,
|
||||||
|
private autoupdateService: AutoupdateService,
|
||||||
|
private DS: DataStoreService
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
// Handler that gets called, if the websocket connection reconnects after a disconnection.
|
||||||
|
// There might have changed something on the server, so we check the operator, if he changed.
|
||||||
|
websocketService.reconnectEvent.subscribe(() => {
|
||||||
|
this.checkOperator();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.bootup();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* the bootup-sequence: Do a whoami request and if it was successful, do
|
||||||
|
* {@method afterLoginBootup}. If not, redirect the user to the login page.
|
||||||
|
*/
|
||||||
|
public bootup(): void {
|
||||||
|
// start autoupdate if the user is logged in:
|
||||||
|
this.operator.whoAmI().subscribe(resp => {
|
||||||
|
this.operator.guestsEnabled = resp.guest_enabled;
|
||||||
|
if (!resp.user && !resp.guest_enabled) {
|
||||||
|
this.redirectUrl = location.pathname;
|
||||||
|
// Goto login, if the user isn't login and guests are not allowed
|
||||||
|
this.router.navigate(['/login']);
|
||||||
|
} else {
|
||||||
|
this.afterLoginBootup(resp.user_id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* the login bootup-sequence: Check (and maybe clear) the cache und setup the DataStore
|
||||||
|
* and websocket.
|
||||||
|
* @param userId
|
||||||
|
*/
|
||||||
|
public afterLoginBootup(userId: number): void {
|
||||||
|
// Else, check, which user was logged in last time
|
||||||
|
this.cacheService.get<number>('lastUserLoggedIn').subscribe((id: number) => {
|
||||||
|
// if the user id changed, reset the cache.
|
||||||
|
if (userId !== id) {
|
||||||
|
this.DS.clear((value: boolean) => {
|
||||||
|
this.setupDataStoreAndWebSocket();
|
||||||
|
});
|
||||||
|
this.cacheService.set('lastUserLoggedIn', userId);
|
||||||
|
} else {
|
||||||
|
this.setupDataStoreAndWebSocket();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Init DS from cache and after this start the websocket service.
|
||||||
|
*/
|
||||||
|
private setupDataStoreAndWebSocket(): void {
|
||||||
|
this.DS.initFromCache().then((changeId: number) => {
|
||||||
|
this.websocketService.connect(
|
||||||
|
false,
|
||||||
|
changeId
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SHuts down OpenSlides. The websocket is closed and the operator is not set.
|
||||||
|
*/
|
||||||
|
public shutdown(): void {
|
||||||
|
this.websocketService.close();
|
||||||
|
this.operator.user = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shutdown and bootup.
|
||||||
|
*/
|
||||||
|
public reboot(): void {
|
||||||
|
this.shutdown();
|
||||||
|
this.bootup();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify that the operator is the same as it was before a reconnect.
|
||||||
|
*/
|
||||||
|
private checkOperator(): void {
|
||||||
|
this.operator.whoAmI().subscribe(resp => {
|
||||||
|
// User logged off.
|
||||||
|
if (!resp.user && !resp.guest_enabled) {
|
||||||
|
this.shutdown();
|
||||||
|
this.router.navigate(['/login']);
|
||||||
|
} else {
|
||||||
|
if (
|
||||||
|
(this.operator.user && this.operator.user.id !== resp.user_id) ||
|
||||||
|
(!this.operator.user && resp.user_id)
|
||||||
|
) {
|
||||||
|
// user changed
|
||||||
|
this.reboot();
|
||||||
|
} else {
|
||||||
|
// User is still the same, but check for missed autoupdates.
|
||||||
|
this.autoupdateService.requestChanges();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
17
client/src/app/core/services/operator.service.spec.ts
Normal file
17
client/src/app/core/services/operator.service.spec.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { TestBed, inject } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { OperatorService } from './operator.service';
|
||||||
|
import { E2EImportsModule } from '../../../e2e-imports.module';
|
||||||
|
|
||||||
|
describe('OperatorService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [E2EImportsModule],
|
||||||
|
providers: [OperatorService]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', inject([OperatorService], (service: OperatorService) => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
}));
|
||||||
|
});
|
157
client/src/app/core/services/operator.service.ts
Normal file
157
client/src/app/core/services/operator.service.ts
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Observable, BehaviorSubject } from 'rxjs';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { tap, catchError } from 'rxjs/operators';
|
||||||
|
import { OpenSlidesComponent } from 'app/openslides.component';
|
||||||
|
import { Group } from 'app/shared/models/users/group';
|
||||||
|
import { User } from '../../shared/models/users/user';
|
||||||
|
import { environment } from 'environments/environment';
|
||||||
|
import { DataStoreService } from './data-store.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permissions on the client are just strings. This makes clear, that
|
||||||
|
* permissions instead of arbitrary strings should be given.
|
||||||
|
*/
|
||||||
|
export type Permission = string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response format of the WHoAMI request.
|
||||||
|
*/
|
||||||
|
interface WhoAmIResponse {
|
||||||
|
user_id: number;
|
||||||
|
guest_enabled: boolean;
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The operator represents the user who is using OpenSlides.
|
||||||
|
*
|
||||||
|
* Changes in operator can be observed, directives do so on order to show
|
||||||
|
* or hide certain information.
|
||||||
|
*
|
||||||
|
* The operator is an {@link OpenSlidesComponent}.
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class OperatorService extends OpenSlidesComponent {
|
||||||
|
/**
|
||||||
|
* The operator.
|
||||||
|
*/
|
||||||
|
private _user: User;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the user that corresponds to operator.
|
||||||
|
*/
|
||||||
|
public get user(): User {
|
||||||
|
return this._user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the current operator.
|
||||||
|
*
|
||||||
|
* The permissions are updated and the new user published.
|
||||||
|
*/
|
||||||
|
public set user(user: User) {
|
||||||
|
this._user = user;
|
||||||
|
this.updatePermissions();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save, if quests are enabled.
|
||||||
|
*/
|
||||||
|
public guestsEnabled: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The permissions of the operator. Updated via {@method updatePermissions}.
|
||||||
|
*/
|
||||||
|
private permissions: Permission[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The subject that can be observed by other instances using observing functions.
|
||||||
|
*/
|
||||||
|
private operatorSubject: BehaviorSubject<User> = new BehaviorSubject<User>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param http HttpClient
|
||||||
|
*/
|
||||||
|
public constructor(private http: HttpClient, private DS: DataStoreService) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.DS.changeObservable.subscribe(newModel => {
|
||||||
|
if (this._user) {
|
||||||
|
if (newModel instanceof Group) {
|
||||||
|
this.updatePermissions();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newModel instanceof User && this._user.id === newModel.id) {
|
||||||
|
this._user = newModel;
|
||||||
|
this.updatePermissions();
|
||||||
|
}
|
||||||
|
} else if (newModel instanceof Group && newModel.id === 1) {
|
||||||
|
// Group 1 (default) for anonymous changed
|
||||||
|
this.updatePermissions();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls `/apps/users/whoami` to find out the real operator.
|
||||||
|
*/
|
||||||
|
public whoAmI(): Observable<WhoAmIResponse> {
|
||||||
|
return this.http.get<WhoAmIResponse>(environment.urlPrefix + '/users/whoami/').pipe(
|
||||||
|
tap((response: WhoAmIResponse) => {
|
||||||
|
if (response && response.user_id) {
|
||||||
|
this.user = new User(response.user);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
catchError(this.handleError())
|
||||||
|
) as Observable<WhoAmIResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the operatorSubject as an observable.
|
||||||
|
*
|
||||||
|
* Services an components can use it to get informed when something changes in
|
||||||
|
* the operator
|
||||||
|
*/
|
||||||
|
public getObservable(): Observable<User> {
|
||||||
|
return this.operatorSubject.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks, if the operator has at least one of the given permissions.
|
||||||
|
* @param checkPerms The permissions to check, if at least one matches.
|
||||||
|
*/
|
||||||
|
public hasPerms(...checkPerms: Permission[]): boolean {
|
||||||
|
if (this._user && this._user.groups_id.includes(2)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return checkPerms.some(permission => {
|
||||||
|
return this.permissions.includes(permission);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the operators permissions and publish the operator afterwards.
|
||||||
|
*/
|
||||||
|
private updatePermissions(): void {
|
||||||
|
this.permissions = [];
|
||||||
|
if (!this.user) {
|
||||||
|
const defaultGroup = this.DS.get<Group>('users/group', 1);
|
||||||
|
if (defaultGroup && defaultGroup.permissions instanceof Array) {
|
||||||
|
this.permissions = defaultGroup.permissions;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const permissionSet = new Set();
|
||||||
|
this.DS.getMany(Group, this.user.groups_id).forEach(group => {
|
||||||
|
group.permissions.forEach(permission => {
|
||||||
|
permissionSet.add(permission);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.permissions = Array.from(permissionSet.values());
|
||||||
|
}
|
||||||
|
// publish changes in the operator.
|
||||||
|
this.operatorSubject.next(this.user);
|
||||||
|
}
|
||||||
|
}
|
17
client/src/app/core/services/prompt.service.spec.ts
Normal file
17
client/src/app/core/services/prompt.service.spec.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { TestBed, inject } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { PromptService } from './prompt.service';
|
||||||
|
import { E2EImportsModule } from 'e2e-imports.module';
|
||||||
|
|
||||||
|
describe('PromptService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [E2EImportsModule],
|
||||||
|
providers: [PromptService]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', inject([PromptService], (service: PromptService) => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
}));
|
||||||
|
});
|
30
client/src/app/core/services/prompt.service.ts
Normal file
30
client/src/app/core/services/prompt.service.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { OpenSlidesComponent } from 'app/openslides.component';
|
||||||
|
import { PromptDialogComponent } from '../../shared/components/prompt-dialog/prompt-dialog.component';
|
||||||
|
import { MatDialog } from '@angular/material';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A general service for prompting 'yes' or 'cancel' thinks from the user.
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class PromptService extends OpenSlidesComponent {
|
||||||
|
public constructor(private dialog: MatDialog) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the dialog. Returns true, if the user accepts.
|
||||||
|
* @param title The title to display in the dialog
|
||||||
|
* @param content The content in the dialog
|
||||||
|
*/
|
||||||
|
public async open(title: string, content: string): Promise<any> {
|
||||||
|
const dialogRef = this.dialog.open(PromptDialogComponent, {
|
||||||
|
width: '250px',
|
||||||
|
data: { title: title, content: content }
|
||||||
|
});
|
||||||
|
|
||||||
|
return dialogRef.afterClosed().toPromise();
|
||||||
|
}
|
||||||
|
}
|
15
client/src/app/core/services/viewport.service.spec.ts
Normal file
15
client/src/app/core/services/viewport.service.spec.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { TestBed, inject } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { ViewportService } from './viewport.service';
|
||||||
|
|
||||||
|
describe('ViewportService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [ViewportService]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', inject([ViewportService], (service: ViewportService) => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
}));
|
||||||
|
});
|
58
client/src/app/core/services/viewport.service.ts
Normal file
58
client/src/app/core/services/viewport.service.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { BreakpointObserver, Breakpoints, BreakpointState } from '@angular/cdk/layout';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Viewport Service
|
||||||
|
*
|
||||||
|
* Uses breakpoint observers to determine the size of the users/operators viewport size (the device)
|
||||||
|
*
|
||||||
|
* ## Example:
|
||||||
|
*
|
||||||
|
* Provide the service via constructor and just use it like
|
||||||
|
*
|
||||||
|
* ```html
|
||||||
|
* <div *ngIf="!vp.isMobile">Will only be shown of not mobile</div>
|
||||||
|
* ```
|
||||||
|
* or
|
||||||
|
* ```ts
|
||||||
|
* if (this.vp.isMobile) {
|
||||||
|
* ...
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class ViewportService {
|
||||||
|
/**
|
||||||
|
* True if Viewport equals mobile or small resolution.
|
||||||
|
*/
|
||||||
|
private _isMobile = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the BreakpointObserver
|
||||||
|
*
|
||||||
|
* @param breakpointObserver
|
||||||
|
*/
|
||||||
|
public constructor(private breakpointObserver: BreakpointObserver) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Needs to be called (exactly) once.
|
||||||
|
* Will observe breakpoints and updates the _isMobile variable
|
||||||
|
*/
|
||||||
|
public checkForChange(): void {
|
||||||
|
this.breakpointObserver
|
||||||
|
.observe([Breakpoints.Small, Breakpoints.HandsetPortrait])
|
||||||
|
.subscribe((state: BreakpointState) => {
|
||||||
|
if (state.matches) {
|
||||||
|
this._isMobile = true;
|
||||||
|
} else {
|
||||||
|
this._isMobile = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public get isMobile(): boolean {
|
||||||
|
return this._isMobile;
|
||||||
|
}
|
||||||
|
}
|
17
client/src/app/core/services/websocket.service.spec.ts
Normal file
17
client/src/app/core/services/websocket.service.spec.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { TestBed, inject } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { WebsocketService } from './websocket.service';
|
||||||
|
import { E2EImportsModule } from '../../../e2e-imports.module';
|
||||||
|
|
||||||
|
describe('WebsocketService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [E2EImportsModule],
|
||||||
|
providers: [WebsocketService]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', inject([WebsocketService], (service: WebsocketService) => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
}));
|
||||||
|
});
|
239
client/src/app/core/services/websocket.service.ts
Normal file
239
client/src/app/core/services/websocket.service.ts
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
import { Injectable, NgZone, EventEmitter } from '@angular/core';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { Observable, Subject } from 'rxjs';
|
||||||
|
import { MatSnackBar, MatSnackBarRef, SimpleSnackBar } from '@angular/material';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key value mapping for params, that should be appendet to the url on a new connection.
|
||||||
|
*/
|
||||||
|
interface QueryParams {
|
||||||
|
[key: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The generic message format in which messages are send and recieved by the server.
|
||||||
|
*/
|
||||||
|
interface WebsocketMessage {
|
||||||
|
type: string;
|
||||||
|
content: any;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service that handles WebSocket connections. Other services can register themselfs
|
||||||
|
* with {@method getOberservable} for a specific type of messages. The content will be published.
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class WebsocketService {
|
||||||
|
/**
|
||||||
|
* The reference to the snackbar entry that is shown, if the connection is lost.
|
||||||
|
*/
|
||||||
|
private connectionErrorNotice: MatSnackBarRef<SimpleSnackBar>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subjects that will be called, if a reconnect was successful.
|
||||||
|
*/
|
||||||
|
private _reconnectEvent: EventEmitter<void> = new EventEmitter<void>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getter for the reconnect event.
|
||||||
|
*/
|
||||||
|
public get reconnectEvent(): EventEmitter<void> {
|
||||||
|
return this._reconnectEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listeners will be nofitied, if the wesocket connection is establiched.
|
||||||
|
*/
|
||||||
|
private _connectEvent: EventEmitter<void> = new EventEmitter<void>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getter for the connect event.
|
||||||
|
*/
|
||||||
|
public get connectEvent(): EventEmitter<void> {
|
||||||
|
return this._connectEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The websocket.
|
||||||
|
*/
|
||||||
|
private websocket: WebSocket;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subjects for types of websocket messages. A subscriber can get an Observable by {@function getOberservable}.
|
||||||
|
*/
|
||||||
|
private subjects: { [type: string]: Subject<any> } = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor that handles the router
|
||||||
|
* @param router the URL Router
|
||||||
|
*/
|
||||||
|
public constructor(
|
||||||
|
private router: Router,
|
||||||
|
private matSnackBar: MatSnackBar,
|
||||||
|
private zone: NgZone,
|
||||||
|
public translate: TranslateService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new WebSocket connection and handles incomming events.
|
||||||
|
*
|
||||||
|
* Uses NgZone to let all callbacks run in the angular context.
|
||||||
|
*/
|
||||||
|
public connect(retry: boolean = false, changeId?: number): void {
|
||||||
|
if (this.websocket) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const queryParams: QueryParams = {};
|
||||||
|
// comment-in if changes IDs are supported on server side.
|
||||||
|
/*if (changeId !== undefined) {
|
||||||
|
queryParams.changeId = changeId.toString();
|
||||||
|
}*/
|
||||||
|
|
||||||
|
// Create the websocket
|
||||||
|
const socketProtocol = this.getWebSocketProtocol();
|
||||||
|
const socketServer = window.location.hostname + ':' + window.location.port;
|
||||||
|
const socketPath = this.getWebSocketPath(queryParams);
|
||||||
|
this.websocket = new WebSocket(socketProtocol + socketServer + socketPath);
|
||||||
|
|
||||||
|
// connection established. If this connect attept was a retry,
|
||||||
|
// The error notice will be removed and the reconnectSubject is published.
|
||||||
|
this.websocket.onopen = (event: Event) => {
|
||||||
|
this.zone.run(() => {
|
||||||
|
if (retry) {
|
||||||
|
if (this.connectionErrorNotice) {
|
||||||
|
this.connectionErrorNotice.dismiss();
|
||||||
|
this.connectionErrorNotice = null;
|
||||||
|
}
|
||||||
|
this._reconnectEvent.emit();
|
||||||
|
}
|
||||||
|
this._connectEvent.emit();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
this.websocket.onmessage = (event: MessageEvent) => {
|
||||||
|
this.zone.run(() => {
|
||||||
|
const message: WebsocketMessage = JSON.parse(event.data);
|
||||||
|
const type: string = message.type;
|
||||||
|
if (type === 'error') {
|
||||||
|
console.error('Websocket error', message.content);
|
||||||
|
} else if (this.subjects[type]) {
|
||||||
|
// Pass the content to the registered subscribers.
|
||||||
|
this.subjects[type].next(message.content);
|
||||||
|
} else {
|
||||||
|
console.log(`Got unknown websocket message type "${type}" with content`, message.content);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
this.websocket.onclose = (event: CloseEvent) => {
|
||||||
|
this.zone.run(() => {
|
||||||
|
this.websocket = null;
|
||||||
|
if (event.code !== 1000) {
|
||||||
|
// 1000 is a normal close, like the close on logout
|
||||||
|
if (!this.connectionErrorNotice) {
|
||||||
|
// So here we have a connection failure that wasn't intendet.
|
||||||
|
this.connectionErrorNotice = this.matSnackBar.open(
|
||||||
|
this.translate.instant('Offline mode: You can use OpenSlides but changes are not saved.'),
|
||||||
|
'',
|
||||||
|
{ duration: 0 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A random retry timeout between 2000 and 5000 ms.
|
||||||
|
const timeout = Math.floor(Math.random() * 3000 + 2000);
|
||||||
|
setTimeout(() => {
|
||||||
|
this.connect((retry = true));
|
||||||
|
}, timeout);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the websocket connection.
|
||||||
|
*/
|
||||||
|
public close(): void {
|
||||||
|
if (this.websocket) {
|
||||||
|
this.websocket.close();
|
||||||
|
this.websocket = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an observable for messages of the given type.
|
||||||
|
* @param type the message type
|
||||||
|
*/
|
||||||
|
public getOberservable<T>(type: string): Observable<T> {
|
||||||
|
if (!this.subjects[type]) {
|
||||||
|
this.subjects[type] = new Subject<T>();
|
||||||
|
}
|
||||||
|
return this.subjects[type].asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a message to the server with the content and the given type.
|
||||||
|
*
|
||||||
|
* @param type the message type
|
||||||
|
* @param content the actual content
|
||||||
|
*/
|
||||||
|
public send<T>(type: string, content: T, id?: string): void {
|
||||||
|
if (!this.websocket) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message: WebsocketMessage = {
|
||||||
|
type: type,
|
||||||
|
content: content,
|
||||||
|
id: id
|
||||||
|
};
|
||||||
|
|
||||||
|
// create message id if not given. Required by the server.
|
||||||
|
if (!message.id) {
|
||||||
|
message.id = '';
|
||||||
|
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
message.id += possible.charAt(Math.floor(Math.random() * possible.length));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.websocket.send(JSON.stringify(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delegates to socket-path for either the side or projector websocket.
|
||||||
|
*/
|
||||||
|
private getWebSocketPath(queryParams: QueryParams = {}): string {
|
||||||
|
// currentRoute does not end with '/'
|
||||||
|
const currentRoute = this.router.url;
|
||||||
|
let path: string;
|
||||||
|
if (currentRoute.includes('/projector') || currentRoute.includes('/real-projector')) {
|
||||||
|
path = '/ws/projector/';
|
||||||
|
} else {
|
||||||
|
path = '/ws/site/';
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys: string[] = Object.keys(queryParams);
|
||||||
|
if (keys.length > 0) {
|
||||||
|
path += keys
|
||||||
|
.map(key => {
|
||||||
|
return key + '=' + queryParams[key];
|
||||||
|
})
|
||||||
|
.join('&');
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* returns the desired websocket protocol
|
||||||
|
*/
|
||||||
|
private getWebSocketProtocol(): string {
|
||||||
|
if (location.protocol === 'https') {
|
||||||
|
return 'wss://';
|
||||||
|
} else {
|
||||||
|
return 'ws://';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
27
client/src/app/openslides.component.ts
Normal file
27
client/src/app/openslides.component.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { Observable, of } from 'rxjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* injects the {@link DataStoreService} to all its children and provides a generic function to catch errors
|
||||||
|
* should be abstract and a mere parent to all {@link DataStoreService} accessors
|
||||||
|
*/
|
||||||
|
export abstract class OpenSlidesComponent {
|
||||||
|
/**
|
||||||
|
* Empty constructor
|
||||||
|
*
|
||||||
|
* Static injection of {@link DataStoreService} in all child instances of OpenSlidesComponent
|
||||||
|
* Throws a warning even tho it is the new syntax. Ignored for now.
|
||||||
|
*/
|
||||||
|
public constructor() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic error handling for everything that makes HTTP Calls
|
||||||
|
* TODO: could have more features
|
||||||
|
* @return an observable error
|
||||||
|
*/
|
||||||
|
public handleError<T>(): (error: any) => Observable<T> {
|
||||||
|
return (error: any): Observable<T> => {
|
||||||
|
console.error(error);
|
||||||
|
return of(error);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,4 @@
|
|||||||
|
<p>
|
||||||
|
projector-container works!
|
||||||
|
Here an iframe with the real-projector is needed
|
||||||
|
</p>
|
@ -0,0 +1,24 @@
|
|||||||
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { ProjectorContainerComponent } from './projector-container.component';
|
||||||
|
|
||||||
|
describe('ProjectorContainerComponent', () => {
|
||||||
|
let component: ProjectorContainerComponent;
|
||||||
|
let fixture: ComponentFixture<ProjectorContainerComponent>;
|
||||||
|
|
||||||
|
beforeEach(async(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
declarations: [ProjectorContainerComponent]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(ProjectorContainerComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,12 @@
|
|||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'os-projector-container',
|
||||||
|
templateUrl: './projector-container.component.html',
|
||||||
|
styleUrls: ['./projector-container.component.css']
|
||||||
|
})
|
||||||
|
export class ProjectorContainerComponent implements OnInit {
|
||||||
|
public constructor() {}
|
||||||
|
|
||||||
|
public ngOnInit(): void {}
|
||||||
|
}
|
@ -0,0 +1,13 @@
|
|||||||
|
import { ProjectorContainerModule } from './projector-container.module';
|
||||||
|
|
||||||
|
describe('ProjectorContainerModule', () => {
|
||||||
|
let projectorContainerModule: ProjectorContainerModule;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
projectorContainerModule = new ProjectorContainerModule();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create an instance', () => {
|
||||||
|
expect(projectorContainerModule).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,13 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
import { ProjectorContainerComponent } from './projector-container.component';
|
||||||
|
import { SharedModule } from 'app/shared/shared.module';
|
||||||
|
import { ProjectorComponent } from './projector/projector.component';
|
||||||
|
import { ProjectorContainerRoutingModule } from './projector/projector-container.routing.module';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [CommonModule, ProjectorContainerRoutingModule, SharedModule],
|
||||||
|
declarations: [ProjectorContainerComponent, ProjectorComponent]
|
||||||
|
})
|
||||||
|
export class ProjectorContainerModule {}
|
@ -0,0 +1,15 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { Routes, RouterModule } from '@angular/router';
|
||||||
|
import { ProjectorContainerComponent } from '../projector-container.component';
|
||||||
|
import { ProjectorComponent } from './projector.component';
|
||||||
|
|
||||||
|
const routes: Routes = [
|
||||||
|
{ path: '', component: ProjectorContainerComponent },
|
||||||
|
{ path: 'real', component: ProjectorComponent }
|
||||||
|
];
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [RouterModule.forChild(routes)],
|
||||||
|
exports: [RouterModule]
|
||||||
|
})
|
||||||
|
export class ProjectorContainerRoutingModule {}
|
@ -0,0 +1,3 @@
|
|||||||
|
<p>
|
||||||
|
projector works!
|
||||||
|
</p>
|
@ -0,0 +1,26 @@
|
|||||||
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { ProjectorComponent } from './projector.component';
|
||||||
|
import { E2EImportsModule } from '../../../e2e-imports.module';
|
||||||
|
|
||||||
|
describe('ProjectorComponent', () => {
|
||||||
|
let component: ProjectorComponent;
|
||||||
|
let fixture: ComponentFixture<ProjectorComponent>;
|
||||||
|
|
||||||
|
beforeEach(async(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [E2EImportsModule],
|
||||||
|
declarations: [ProjectorComponent]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(ProjectorComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,19 @@
|
|||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { BaseComponent } from 'app/base.component';
|
||||||
|
import { Title } from '@angular/platform-browser';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'os-projector',
|
||||||
|
templateUrl: './projector.component.html',
|
||||||
|
styleUrls: ['./projector.component.css']
|
||||||
|
})
|
||||||
|
export class ProjectorComponent extends BaseComponent implements OnInit {
|
||||||
|
public constructor(titleService: Title, translate: TranslateService) {
|
||||||
|
super(titleService, translate);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnInit(): void {
|
||||||
|
super.setTitle('Projector');
|
||||||
|
}
|
||||||
|
}
|
116
client/src/app/shared/animations.ts
Normal file
116
client/src/app/shared/animations.ts
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import { trigger, animate, transition, style, query, stagger, group } from '@angular/animations';
|
||||||
|
|
||||||
|
export const pageTransition = trigger('pageTransition', [
|
||||||
|
transition('* => *', [
|
||||||
|
/** this will avoid the dom-copy-effect */
|
||||||
|
query(':enter, :leave', style({ position: 'absolute', width: '100%' }), { optional: true }),
|
||||||
|
/** keep the dom clean - let all items "just" enter */
|
||||||
|
query(':enter mat-card', [style({ opacity: 0 })], { optional: true }),
|
||||||
|
query(':enter .on-transition-fade', [style({ opacity: 0 })], { optional: true }),
|
||||||
|
query(':enter mat-row', [style({ opacity: 0 })], { optional: true }),
|
||||||
|
query(':enter mat-expansion-panel', [style({ opacity: 0 })], { optional: true }),
|
||||||
|
|
||||||
|
/** parallel vanishing */
|
||||||
|
group([
|
||||||
|
/** animate fade out for the selected components */
|
||||||
|
query(
|
||||||
|
':leave .on-transition-fade',
|
||||||
|
[
|
||||||
|
style({ opacity: 1 }),
|
||||||
|
animate(
|
||||||
|
'200ms ease-in-out',
|
||||||
|
style({
|
||||||
|
transform: 'translateY(0%)',
|
||||||
|
opacity: 0
|
||||||
|
})
|
||||||
|
)
|
||||||
|
],
|
||||||
|
{ optional: true }
|
||||||
|
),
|
||||||
|
/** how the material cards are leaving */
|
||||||
|
query(
|
||||||
|
':leave mat-card',
|
||||||
|
[
|
||||||
|
style({ transform: 'translateY(0%)', opacity: 1 }),
|
||||||
|
animate(
|
||||||
|
'200ms ease-in-out',
|
||||||
|
style({
|
||||||
|
transform: 'translateY(0%)',
|
||||||
|
opacity: 0
|
||||||
|
})
|
||||||
|
)
|
||||||
|
],
|
||||||
|
{ optional: true }
|
||||||
|
),
|
||||||
|
query(
|
||||||
|
':leave mat-row',
|
||||||
|
[
|
||||||
|
style({ transform: 'translateY(0%)', opacity: 1 }),
|
||||||
|
animate(
|
||||||
|
'200ms ease-in-out',
|
||||||
|
style({
|
||||||
|
transform: 'translateY(0%)',
|
||||||
|
opacity: 0
|
||||||
|
})
|
||||||
|
)
|
||||||
|
],
|
||||||
|
{ optional: true }
|
||||||
|
),
|
||||||
|
query(
|
||||||
|
':leave mat-expansion-panel',
|
||||||
|
[
|
||||||
|
style({ transform: 'translateY(0%)', opacity: 1 }),
|
||||||
|
animate(
|
||||||
|
'200ms ease-in-out',
|
||||||
|
style({
|
||||||
|
transform: 'translateY(0%)',
|
||||||
|
opacity: 0
|
||||||
|
})
|
||||||
|
)
|
||||||
|
],
|
||||||
|
{ optional: true }
|
||||||
|
)
|
||||||
|
]),
|
||||||
|
|
||||||
|
/** parallel appearing */
|
||||||
|
group([
|
||||||
|
/** animate fade in for the selected components */
|
||||||
|
query(':enter .on-transition-fade', [style({ opacity: 0 }), animate('0.2s', style({ opacity: 1 }))], {
|
||||||
|
optional: true
|
||||||
|
}),
|
||||||
|
/** how the mat cards enters the scene */
|
||||||
|
query(
|
||||||
|
':enter mat-card',
|
||||||
|
/** stagger = "one after another" with a distance of 50ms" */
|
||||||
|
stagger(50, [
|
||||||
|
style({ transform: 'translateY(50px)' }),
|
||||||
|
animate('300ms ease-in-out', style({ transform: 'translateY(0px)', opacity: 1 }))
|
||||||
|
]),
|
||||||
|
{ optional: true }
|
||||||
|
),
|
||||||
|
query(
|
||||||
|
':enter mat-row',
|
||||||
|
/** stagger = "one after another" with a distance of 50ms" */
|
||||||
|
stagger(30, [
|
||||||
|
style({ transform: 'translateY(24px)' }),
|
||||||
|
animate('200ms ease-in-out', style({ transform: 'translateY(0px)', opacity: 1 }))
|
||||||
|
]),
|
||||||
|
{ optional: true }
|
||||||
|
),
|
||||||
|
query(
|
||||||
|
':enter mat-expansion-panel',
|
||||||
|
/** stagger = "one after another" with a distance of 50ms" */
|
||||||
|
stagger(100, [
|
||||||
|
style({ transform: 'translateY(50px)' }),
|
||||||
|
animate('300ms ease-in-out', style({ transform: 'translateY(0px)', opacity: 1 }))
|
||||||
|
]),
|
||||||
|
{ optional: true }
|
||||||
|
)
|
||||||
|
])
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const navItemAnim = trigger('navItemAnim', [
|
||||||
|
transition(':enter', [style({ transform: 'translateX(-100%)' }), animate('500ms ease')]),
|
||||||
|
transition(':leave', [style({ transform: 'translateX(100%)' }), animate('500ms ease')])
|
||||||
|
]);
|
@ -0,0 +1,12 @@
|
|||||||
|
<mat-toolbar color='primary' class="footer">
|
||||||
|
<mat-toolbar-row>
|
||||||
|
<button mat-button class="footer-link" [routerLink]=legalNoticeUrl>
|
||||||
|
<span translate>Legal Notice</span>
|
||||||
|
</button>
|
||||||
|
<button mat-button class="footer-link" [routerLink]=privacyPolicyUrl>
|
||||||
|
<span translate>Privacy Policy</span>
|
||||||
|
</button>
|
||||||
|
<span class="footer-right">© <span translate>Copyright by</span> <a href='https://openslides.org/'>OpenSlides</a>
|
||||||
|
</span>
|
||||||
|
</mat-toolbar-row>
|
||||||
|
</mat-toolbar>
|
@ -0,0 +1,11 @@
|
|||||||
|
.footer-link,
|
||||||
|
.footer-right {
|
||||||
|
font-size: 12px;
|
||||||
|
z-index: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-right {
|
||||||
|
a {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,25 @@
|
|||||||
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { FooterComponent } from './footer.component';
|
||||||
|
import { E2EImportsModule } from '../../../../e2e-imports.module';
|
||||||
|
|
||||||
|
describe('FooterComponent', () => {
|
||||||
|
let component: FooterComponent;
|
||||||
|
let fixture: ComponentFixture<FooterComponent>;
|
||||||
|
|
||||||
|
beforeEach(async(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [E2EImportsModule]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(FooterComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
47
client/src/app/shared/components/footer/footer.component.ts
Normal file
47
client/src/app/shared/components/footer/footer.component.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable footer Apps.
|
||||||
|
*
|
||||||
|
* ## Examples:
|
||||||
|
*
|
||||||
|
* ### Usage of the selector:
|
||||||
|
*
|
||||||
|
* ```html
|
||||||
|
* <os-footer></os-footer>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'os-footer',
|
||||||
|
templateUrl: './footer.component.html',
|
||||||
|
styleUrls: ['./footer.component.scss']
|
||||||
|
})
|
||||||
|
export class FooterComponent implements OnInit {
|
||||||
|
/**
|
||||||
|
* Indicates to location of the legal notice
|
||||||
|
*/
|
||||||
|
public legalNoticeUrl = '/legalnotice';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicated the location of the privacy policy
|
||||||
|
*/
|
||||||
|
public privacyPolicyUrl = '/privacypolicy';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty constructor
|
||||||
|
*/
|
||||||
|
public constructor(private route: ActivatedRoute) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If on login page, redirect the legal notice and privacy policy not to /URL
|
||||||
|
* but to /login/URL
|
||||||
|
*/
|
||||||
|
|
||||||
|
public ngOnInit(): void {
|
||||||
|
if (this.route.snapshot.url[0] && this.route.snapshot.url[0].path === 'login') {
|
||||||
|
this.legalNoticeUrl = '/login/legalnotice';
|
||||||
|
this.privacyPolicyUrl = '/login/privacypolicy';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,27 @@
|
|||||||
|
<mat-toolbar color='primary'>
|
||||||
|
<button *ngIf="plusButton" class='head-button on-transition-fade' (click)=clickPlusButton()
|
||||||
|
mat-fab>
|
||||||
|
<mat-icon>add</mat-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span class='app-name on-transition-fade'>
|
||||||
|
{{ appName | translate }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class='spacer'></span>
|
||||||
|
|
||||||
|
<button *ngIf="menuList" class='on-transition-fade' [matMenuTriggerFor]="ellipsisMenu" mat-icon-button>
|
||||||
|
<mat-icon>more_vert</mat-icon>
|
||||||
|
</button>
|
||||||
|
</mat-toolbar>
|
||||||
|
|
||||||
|
<mat-menu #ellipsisMenu="matMenu">
|
||||||
|
|
||||||
|
<ng-container *ngFor="let item of menuList">
|
||||||
|
|
||||||
|
<button mat-menu-item *osPerms="item.perm" (click)=clickMenu(item)>
|
||||||
|
<mat-icon *ngIf="item.icon">{{ item.icon }}</mat-icon>
|
||||||
|
{{item.text | translate}}
|
||||||
|
</button>
|
||||||
|
</ng-container>
|
||||||
|
</mat-menu>
|
@ -0,0 +1,4 @@
|
|||||||
|
.head-button {
|
||||||
|
bottom: -30px;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
@ -0,0 +1,25 @@
|
|||||||
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { HeadBarComponent } from './head-bar.component';
|
||||||
|
import { E2EImportsModule } from '../../../../e2e-imports.module';
|
||||||
|
|
||||||
|
describe('HeadBarComponent', () => {
|
||||||
|
let component: HeadBarComponent;
|
||||||
|
let fixture: ComponentFixture<HeadBarComponent>;
|
||||||
|
|
||||||
|
beforeEach(async(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [E2EImportsModule]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(HeadBarComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
110
client/src/app/shared/components/head-bar/head-bar.component.ts
Normal file
110
client/src/app/shared/components/head-bar/head-bar.component.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable head bar component for Apps.
|
||||||
|
*
|
||||||
|
* Will translate the title automatically.
|
||||||
|
*
|
||||||
|
* Use `PlusButton=true` and `(plusButtonClicked)=myFunction()` if a plus button is needed
|
||||||
|
*
|
||||||
|
* Use `[menuLust]=myArray` and `(ellipsisMenuItem)=myFunction($event)` if a menu is needed
|
||||||
|
*
|
||||||
|
* ## Examples:
|
||||||
|
*
|
||||||
|
* ### Usage of the selector:
|
||||||
|
*
|
||||||
|
* ```html
|
||||||
|
* <os-head-bar
|
||||||
|
* appName="Files"
|
||||||
|
* plusButton=true
|
||||||
|
* [menuList]=myMenu
|
||||||
|
* (plusButtonClicked)=onPlusButton()
|
||||||
|
* (ellipsisMenuItem)=onEllipsisItem($event)>
|
||||||
|
* </os-head-bar>
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ### Declaration of a menu provided as `[menuList]=myMenu`:
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* myMenu = [
|
||||||
|
* {
|
||||||
|
* text: 'Download All',
|
||||||
|
* icon: 'save_alt',
|
||||||
|
* action: 'downloadAllFiles'
|
||||||
|
* },
|
||||||
|
* ];
|
||||||
|
* ```
|
||||||
|
* The parent needs to react to `action` like the following.
|
||||||
|
* This will execute a function with the name provided in the
|
||||||
|
* `action` field.
|
||||||
|
* ```ts
|
||||||
|
* onEllipsisItem(event: any) {
|
||||||
|
* if (event.action) {
|
||||||
|
* this[event.action]();
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'os-head-bar',
|
||||||
|
templateUrl: './head-bar.component.html',
|
||||||
|
styleUrls: ['./head-bar.component.scss']
|
||||||
|
})
|
||||||
|
export class HeadBarComponent implements OnInit {
|
||||||
|
/**
|
||||||
|
* Input declaration for the app name
|
||||||
|
*/
|
||||||
|
@Input()
|
||||||
|
public appName: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if there should be a plus button.
|
||||||
|
*/
|
||||||
|
@Input()
|
||||||
|
public plusButton: false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If not empty shows a ellipsis menu on the right side
|
||||||
|
*
|
||||||
|
* The parent needs to provide a menu, i.e `[menuList]=myMenu`.
|
||||||
|
*/
|
||||||
|
@Input()
|
||||||
|
public menuList: any[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit a signal to the parent component if the plus button was clicked
|
||||||
|
*/
|
||||||
|
@Output()
|
||||||
|
public plusButtonClicked = new EventEmitter<boolean>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit a signal to the parent of an item in the menuList was selected.
|
||||||
|
*/
|
||||||
|
@Output()
|
||||||
|
public ellipsisMenuItem = new EventEmitter<any>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty constructor
|
||||||
|
*/
|
||||||
|
public constructor() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* empty onInit
|
||||||
|
*/
|
||||||
|
public ngOnInit(): void {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits a signal to the parent if an item in the menu was clicked.
|
||||||
|
* @param item
|
||||||
|
*/
|
||||||
|
public clickMenu(item: any): void {
|
||||||
|
this.ellipsisMenuItem.emit(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits a signal to the parent if
|
||||||
|
*/
|
||||||
|
public clickPlusButton(): void {
|
||||||
|
this.plusButtonClicked.emit(true);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,24 @@
|
|||||||
|
<mat-card class="os-card on-transition-fade">
|
||||||
|
<div>
|
||||||
|
<div class='legal-notice-text' [innerHtml]='legalNotice'></div>
|
||||||
|
<mat-divider></mat-divider>
|
||||||
|
<div *ngIf="versionInfo" class='version-text'>
|
||||||
|
<a [attr.href]="versionInfo.openslides_url" target="_blank">
|
||||||
|
OpenSlides {{ versionInfo.openslides_version }}
|
||||||
|
</a>
|
||||||
|
(<span translate>License</span>: {{ versionInfo.openslides_license }})
|
||||||
|
|
||||||
|
<div *ngIf="versionInfo.plugins.length">
|
||||||
|
<div translate>Installed plugins</div>:
|
||||||
|
<div *ngFor="let plugin of versionInfo.plugins">
|
||||||
|
<a [attr.href]="plugin.url" target="_blank">
|
||||||
|
{{ plugin.verbose_name }} {{ plugin.version }}
|
||||||
|
</a>
|
||||||
|
<div *ngIf="plugin.license">
|
||||||
|
(<span translate>License</span>: {{ plugin.license }})
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-card>
|
@ -0,0 +1,9 @@
|
|||||||
|
.legal-notice-text {
|
||||||
|
display: block;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-text {
|
||||||
|
display: block;
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
@ -0,0 +1,25 @@
|
|||||||
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { LegalNoticeContentComponent } from './legal-notice-content.component';
|
||||||
|
import { E2EImportsModule } from '../../../../e2e-imports.module';
|
||||||
|
|
||||||
|
describe('LegalNoticeComponent', () => {
|
||||||
|
let component: LegalNoticeContentComponent;
|
||||||
|
let fixture: ComponentFixture<LegalNoticeContentComponent>;
|
||||||
|
|
||||||
|
beforeEach(async(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [E2EImportsModule]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(LegalNoticeContentComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,106 @@
|
|||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
import { LoginDataService } from '../../../core/services/login-data.service';
|
||||||
|
import { environment } from 'environments/environment';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Characterize a plugin. This data is retrieved from the server
|
||||||
|
*/
|
||||||
|
interface PluginDescription {
|
||||||
|
/**
|
||||||
|
* The name of the plugin
|
||||||
|
*/
|
||||||
|
verbose_name: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* the version
|
||||||
|
*/
|
||||||
|
version: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The url to the main webpage of the plugin
|
||||||
|
*/
|
||||||
|
url: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The license
|
||||||
|
*/
|
||||||
|
license: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents metadata about the current installation.
|
||||||
|
*/
|
||||||
|
interface VersionResponse {
|
||||||
|
/**
|
||||||
|
* The lience string. Like 'MIT', 'GPLv2', ...
|
||||||
|
*/
|
||||||
|
openslides_license: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The URl to the main webpage of OpenSlides.
|
||||||
|
*/
|
||||||
|
openslides_url: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current version.
|
||||||
|
*/
|
||||||
|
openslides_version: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list of installed plugins.
|
||||||
|
*/
|
||||||
|
plugins: PluginDescription[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared component to hold the content of the Legal Notice.
|
||||||
|
* Used in login and site container.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'os-legal-notice-content',
|
||||||
|
templateUrl: './legal-notice-content.component.html',
|
||||||
|
styleUrls: ['./legal-notice-content.component.scss']
|
||||||
|
})
|
||||||
|
export class LegalNoticeContentComponent implements OnInit {
|
||||||
|
/**
|
||||||
|
* The legal notive text for the ui.
|
||||||
|
*/
|
||||||
|
public legalNotice: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the version info retrieved from the server for the ui.
|
||||||
|
*/
|
||||||
|
public versionInfo: VersionResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports the LoginDataService, the translations and and HTTP Service
|
||||||
|
* @param loginDataService
|
||||||
|
* @param translate
|
||||||
|
* @param http
|
||||||
|
*/
|
||||||
|
public constructor(
|
||||||
|
private loginDataService: LoginDataService,
|
||||||
|
private translate: TranslateService,
|
||||||
|
private http: HttpClient
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes for the legal notice text.
|
||||||
|
*/
|
||||||
|
public ngOnInit(): void {
|
||||||
|
this.loginDataService.legal_notice.subscribe(legalNotice => {
|
||||||
|
if (legalNotice) {
|
||||||
|
this.legalNotice = this.translate.instant(legalNotice);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Query the version info.
|
||||||
|
this.http
|
||||||
|
.get<VersionResponse>(environment.urlPrefix + '/core/version/', {})
|
||||||
|
.subscribe((info: VersionResponse) => {
|
||||||
|
this.versionInfo = info;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
<mat-card class='os-card on-transition-fade'>
|
||||||
|
<div *ngIf='privacyPolicy' [innerHtml]='privacyPolicy'></div>
|
||||||
|
<div *ngIf='!privacyPolicy' translate>
|
||||||
|
The event manager hasn't set up a privacy policy yet.
|
||||||
|
</div>
|
||||||
|
</mat-card>
|
@ -0,0 +1,3 @@
|
|||||||
|
mat-card {
|
||||||
|
height: 100%;
|
||||||
|
}
|
@ -0,0 +1,25 @@
|
|||||||
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { PrivacyPolicyContentComponent } from './privacy-policy-content.component';
|
||||||
|
import { E2EImportsModule } from '../../../../e2e-imports.module';
|
||||||
|
|
||||||
|
describe('PrivacyPolicyComponent', () => {
|
||||||
|
let component: PrivacyPolicyContentComponent;
|
||||||
|
let fixture: ComponentFixture<PrivacyPolicyContentComponent>;
|
||||||
|
|
||||||
|
beforeEach(async(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [E2EImportsModule]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(PrivacyPolicyContentComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,37 @@
|
|||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { LoginDataService } from '../../../core/services/login-data.service';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared component to hold the content of the Privacy Policy.
|
||||||
|
* Used in login and site container.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'os-privacy-policy-content',
|
||||||
|
templateUrl: './privacy-policy-content.component.html',
|
||||||
|
styleUrls: ['./privacy-policy-content.component.scss']
|
||||||
|
})
|
||||||
|
export class PrivacyPolicyContentComponent implements OnInit {
|
||||||
|
/**
|
||||||
|
* The actual privacy policy as string
|
||||||
|
*/
|
||||||
|
public privacyPolicy: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports the loginDataService and the translation service
|
||||||
|
* @param loginDataService Login Data
|
||||||
|
* @param translate for the translation
|
||||||
|
*/
|
||||||
|
public constructor(private loginDataService: LoginDataService, private translate: TranslateService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes for the privacy policy text
|
||||||
|
*/
|
||||||
|
public ngOnInit(): void {
|
||||||
|
this.loginDataService.privacy_policy.subscribe(privacyPolicy => {
|
||||||
|
if (privacyPolicy) {
|
||||||
|
this.privacyPolicy = this.translate.instant(privacyPolicy);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
<h2 mat-dialog-title>{{ data.title | translate }}</h2>
|
||||||
|
<mat-dialog-content>{{ data.content | translate }}</mat-dialog-content>
|
||||||
|
<mat-dialog-actions>
|
||||||
|
<button mat-button [mat-dialog-close]="true" color="warn" translate>Yes</button>
|
||||||
|
<button mat-button [mat-dialog-close]="false" translate>Cancel</button>
|
||||||
|
</mat-dialog-actions>
|
@ -0,0 +1,26 @@
|
|||||||
|
import { async, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
// import { PromptDialogComponent } from './prompt-dialog.component';
|
||||||
|
import { E2EImportsModule } from 'e2e-imports.module';
|
||||||
|
|
||||||
|
describe('PromptDialogComponent', () => {
|
||||||
|
// let component: PromptDialogComponent;
|
||||||
|
// let fixture: ComponentFixture<PromptDialogComponent>;
|
||||||
|
|
||||||
|
beforeEach(async(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [E2EImportsModule]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
// TODO: You cannot create this component in the standard way. Needs different testing.
|
||||||
|
beforeEach(() => {
|
||||||
|
/*fixture = TestBed.createComponent(PromptDialogComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();*/
|
||||||
|
});
|
||||||
|
|
||||||
|
/*it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});*/
|
||||||
|
});
|
@ -0,0 +1,21 @@
|
|||||||
|
import { Component, Inject } from '@angular/core';
|
||||||
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material';
|
||||||
|
|
||||||
|
interface PromptDialogData {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A simple prompt dialog. Takes a title and content.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'os-prompt-dialog',
|
||||||
|
templateUrl: './prompt-dialog.component.html'
|
||||||
|
})
|
||||||
|
export class PromptDialogComponent {
|
||||||
|
public constructor(
|
||||||
|
public dialogRef: MatDialogRef<PromptDialogComponent>,
|
||||||
|
@Inject(MAT_DIALOG_DATA) public data: PromptDialogData
|
||||||
|
) {}
|
||||||
|
}
|
@ -0,0 +1,22 @@
|
|||||||
|
<mat-form-field [formGroup]="form">
|
||||||
|
<mat-select [formControl]="formControl" [placeholder]="listname" [multiple]="multiple" #thisSelector>
|
||||||
|
<ngx-mat-select-search [formControl]="filterControl"></ngx-mat-select-search>
|
||||||
|
<div *ngIf="!multiple">
|
||||||
|
<mat-option [value]="null"><span translate>None</span></mat-option>
|
||||||
|
<mat-divider></mat-divider>
|
||||||
|
</div>
|
||||||
|
<mat-option *ngFor="let selectedItem of filteredItems | async" [value]="selectedItem.id">
|
||||||
|
{{selectedItem.getTitle(translate)}}
|
||||||
|
</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<div *ngIf="dispSelected">
|
||||||
|
<p>
|
||||||
|
<span translate>Selected Values</span>:
|
||||||
|
</p>
|
||||||
|
<mat-chip-list #chipList>
|
||||||
|
<mat-chip *ngFor="let selectedItem of thisSelector?.value" (removed)="remove(selectedItem)">{{selectedItem.name}}
|
||||||
|
<mat-icon (click)="remove(selectedItem)">cancel</mat-icon>
|
||||||
|
</mat-chip>
|
||||||
|
</mat-chip-list>
|
||||||
|
</div>
|
@ -0,0 +1,48 @@
|
|||||||
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { SearchValueSelectorComponent, Selectable } from './search-value-selector.component';
|
||||||
|
import { E2EImportsModule } from '../../../../e2e-imports.module';
|
||||||
|
import { ViewChild, Component } from '@angular/core';
|
||||||
|
import { BehaviorSubject } from 'rxjs';
|
||||||
|
import { FormControl, FormBuilder } from '@angular/forms';
|
||||||
|
|
||||||
|
describe('SearchValueSelectorComponent', () => {
|
||||||
|
@Component({
|
||||||
|
selector: 'os-host-component',
|
||||||
|
template: '<os-search-value-selector></os-search-value-selector>'
|
||||||
|
})
|
||||||
|
class TestHostComponent {
|
||||||
|
@ViewChild(SearchValueSelectorComponent)
|
||||||
|
public searchValueSelectorComponent: SearchValueSelectorComponent;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hostComponent: TestHostComponent;
|
||||||
|
let hostFixture: ComponentFixture<TestHostComponent>;
|
||||||
|
|
||||||
|
beforeEach(async(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [E2EImportsModule],
|
||||||
|
declarations: [TestHostComponent]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
hostFixture = TestBed.createComponent(TestHostComponent);
|
||||||
|
hostComponent = hostFixture.componentInstance;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
const subject: BehaviorSubject<Selectable[]> = new BehaviorSubject([]);
|
||||||
|
hostComponent.searchValueSelectorComponent.InputListValues = subject;
|
||||||
|
|
||||||
|
const formBuilder: FormBuilder = TestBed.get(FormBuilder);
|
||||||
|
const formGroup = formBuilder.group({
|
||||||
|
testArray: []
|
||||||
|
});
|
||||||
|
hostComponent.searchValueSelectorComponent.form = formGroup;
|
||||||
|
hostComponent.searchValueSelectorComponent.formControl = <FormControl>formGroup.get('testArray');
|
||||||
|
|
||||||
|
hostFixture.detectChanges();
|
||||||
|
expect(hostComponent.searchValueSelectorComponent).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user