Merge pull request #3923 from ostcar/new-master
Merge OpenSlides-3 branch into the master branch
This commit is contained in:
commit
becdef26a8
45
.gitignore
vendored
45
.gitignore
vendored
@ -6,7 +6,7 @@
|
||||
*~
|
||||
|
||||
# Virtual Environment
|
||||
.virtualenv/*
|
||||
.virtualenv*/*
|
||||
.venv/*
|
||||
|
||||
# Javascript tools and libraries
|
||||
@ -30,6 +30,7 @@ debug/*
|
||||
# Unit test and coverage reports
|
||||
.coverage
|
||||
tests/file/*
|
||||
.pytest_cache
|
||||
|
||||
# Plugin development
|
||||
openslides_*
|
||||
@ -37,5 +38,43 @@ openslides_*
|
||||
# Mypy cache for typechecking
|
||||
.mypy_cache
|
||||
|
||||
# Development of a new client. Easier to switch branches with this entry
|
||||
client/*
|
||||
# OpenSlides 3 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
|
||||
|
88
.travis.yml
88
.travis.yml
@ -1,36 +1,66 @@
|
||||
language: python
|
||||
dist: xenial
|
||||
sudo: true
|
||||
cache:
|
||||
|
||||
matrix:
|
||||
include:
|
||||
- language: python
|
||||
cache:
|
||||
pip: true
|
||||
yarn: true
|
||||
python:
|
||||
- "3.5"
|
||||
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
|
||||
|
||||
- language: python
|
||||
cache:
|
||||
pip: true
|
||||
python:
|
||||
- "3.7"
|
||||
env:
|
||||
- TRAVIS_NODE_VERSION="10.5"
|
||||
before_install:
|
||||
- nvm install $TRAVIS_NODE_VERSION
|
||||
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
|
||||
|
||||
- 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"
|
||||
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
|
||||
- coverage report --fail-under=35
|
||||
|
||||
- DJANGO_SETTINGS_MODULE='tests.settings' coverage run ./manage.py test tests.integration
|
||||
- coverage report --fail-under=73
|
||||
|
||||
- DJANGO_SETTINGS_MODULE='tests.settings' ./manage.py test tests.old
|
||||
- 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/
|
||||
|
||||
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)
|
||||
========================
|
||||
`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
|
||||
'''''''''''''''''''''
|
||||
|
||||
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
|
||||
`Git <http://git-scm.com/>`_ on your system. You also need build-essential
|
||||
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
|
||||
|
||||
*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
|
||||
'''''''''''''''''''''''''''''
|
||||
@ -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.
|
||||
|
||||
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::
|
||||
|
||||
$ 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
|
||||
|
||||
To start OpenSlides with Daphne and four workers (avoid concurrent write
|
||||
requests or use PostgreSQL, see below) run::
|
||||
To start OpenSlides with Daphne run::
|
||||
|
||||
$ 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::
|
||||
|
||||
$ 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
|
||||
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 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
|
||||
@ -152,8 +143,7 @@ OpenSlides in big mode
|
||||
|
||||
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
|
||||
OpenSlides interface server. Optionally you can use `Geiss
|
||||
<https://github.com/ostcar/geiss/>`_ as interface server instead of Daphne.
|
||||
OpenSlides interface server.
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
2. Install additional packages
|
||||
------------------------------
|
||||
|
||||
Install some more required Python packages::
|
||||
|
||||
$ pip install -r requirements_big_mode.txt
|
||||
|
||||
|
||||
3. Change OpenSlides settings
|
||||
2. Change OpenSlides settings
|
||||
-----------------------------
|
||||
|
||||
Create OpenSlides settings file if it does not exist::
|
||||
@ -197,34 +180,26 @@ Populate your new database::
|
||||
$ 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)::
|
||||
|
||||
$ python manage.py runworker&
|
||||
$ python manage.py runworker&
|
||||
$ python manage.py runworker&
|
||||
$ python manage.py runworker&
|
||||
|
||||
To start Daphne as protocol server run::
|
||||
To start gunicorn with uvicorn as protocol server run::
|
||||
|
||||
$ export DJANGO_SETTINGS_MODULE=settings
|
||||
$ 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::
|
||||
|
||||
$ 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 {
|
||||
listen 80;
|
||||
@ -232,9 +207,6 @@ This is an example configuration for a single Daphne/Geiss listen on port 8000::
|
||||
|
||||
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.*$ {
|
||||
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 {
|
||||
alias <your path to>/collected-static;
|
||||
}
|
||||
location ~* ^/(?!ws|wss|media|rest|views).*$ {
|
||||
rewrite ^.*$ /static/templates/index.html;
|
||||
}
|
||||
|
||||
location / {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
RUN apt-get -y update && apt-get -y upgrade
|
||||
RUN apt-get install -y libpq-dev supervisor curl vim
|
||||
FROM python:3.7-slim
|
||||
RUN apt-get -y update && \
|
||||
apt-get -y upgrade && \
|
||||
apt-get install -y libpq-dev supervisor curl wget xz-utils bzip2 git gcc
|
||||
RUN useradd -m openslides
|
||||
|
||||
## BUILD JS STUFF
|
||||
@ -18,7 +19,7 @@ RUN node_modules/.bin/gulp --production
|
||||
|
||||
# INSTALL PYTHON DEPENDENCIES
|
||||
USER root
|
||||
RUN pip install -r /app/requirements_big_mode.txt
|
||||
RUN pip install .[big_mode]
|
||||
|
||||
## Clean up
|
||||
RUN apt-get remove -y python3-pip wget curl
|
||||
|
@ -2,8 +2,8 @@ include AUTHORS
|
||||
include CHANGELOG
|
||||
include LICENSE
|
||||
include README.rst
|
||||
include requirements_production.txt
|
||||
include requirements_big_mode.txt
|
||||
include requirements/production.txt
|
||||
include requirements/big_mode.txt
|
||||
include bower.json
|
||||
recursive-include openslides *.*
|
||||
exclude openslides/__pycache__/*
|
||||
|
12
README.rst
12
README.rst
@ -26,7 +26,7 @@ Installation
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
This will install all required Python packages (see
|
||||
``requirements_production.txt``).
|
||||
``requirements/production.txt``).
|
||||
|
||||
|
||||
d. Start OpenSlides
|
||||
@ -139,23 +139,21 @@ file (usually called settings.py).
|
||||
|
||||
The configuration values that have to be altered are:
|
||||
|
||||
* CACHES
|
||||
* CHANNEL_LAYERS
|
||||
* DATABASES
|
||||
* SESSION_ENGINE
|
||||
* REDIS_ADDRESS
|
||||
|
||||
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
|
||||
interface server. You also should use a database like PostgreSQL and Redis
|
||||
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
|
||||
<https://github.com/OpenSlides/OpenSlides/blob/master/DEVELOPMENT.rst>`_ and:
|
||||
|
||||
* 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://docs.djangoproject.com/en/1.10/ref/settings/#databases
|
||||
|
||||
@ -165,7 +163,7 @@ Used software
|
||||
|
||||
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``)
|
||||
|
||||
|
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