diff --git a/.docker/nginx.conf b/.docker/nginx.conf index d0a557b11..8fe03dbc7 100644 --- a/.docker/nginx.conf +++ b/.docker/nginx.conf @@ -7,7 +7,6 @@ events { worker_connections 1024; } - http { proxy_temp_path /tmp/proxy_temp; client_body_temp_path /tmp/client_temp; @@ -18,7 +17,7 @@ http { include /etc/nginx/mime.types; default_type application/octet-stream; - set_real_ip_from 172.16.0.0/8; + set_real_ip_from 172.16.0.0/16; real_ip_recursive on; real_ip_header X-Forwarded-For; diff --git a/.docker/templates/default.conf.template b/.docker/templates/default.conf.template index df4744c3f..991a52942 100644 --- a/.docker/templates/default.conf.template +++ b/.docker/templates/default.conf.template @@ -4,6 +4,13 @@ server { root ${NGINX_WEB_ROOT}; + client_max_body_size ${NGINX_MAX_BODY_SIZE}; + + # This also needs to be set in the single server tag and not only in http. + set_real_ip_from 172.16.0.0/16; + real_ip_recursive on; + real_ip_header X-Forwarded-For; + location / { # try to serve file directly, fallback to index.php try_files $uri /index.php$is_args$args; @@ -30,6 +37,17 @@ server { internal; } + location /vite { + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header Host $http_host; + proxy_pass http://node:3000; + proxy_http_version 1.1; + + # Enable WebSocket support for HMR + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + location ~ \.php$ { return 404; } diff --git a/.docker/vhost.conf b/.docker/vhost.conf deleted file mode 100644 index b50231d44..000000000 --- a/.docker/vhost.conf +++ /dev/null @@ -1,33 +0,0 @@ -server { - listen 8080; - server_name localhost; - root /app/public; - - location / { - # try to serve file directly, fallback to index.php - try_files $uri /index.php$is_args$args; - } - - location ~ ^/index\.php(/|$) { - fastcgi_buffers 16 32k; - fastcgi_buffer_size 64k; - fastcgi_busy_buffers_size 64k; - - fastcgi_pass phpfpm:9000; - fastcgi_split_path_info ^(.+\.php)(/.*)$; - include fastcgi_params; - - fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; - fastcgi_param DOCUMENT_ROOT $realpath_root; - - internal; - } - - location ~ \.php$ { - return 404; - } - - # Send log message to files symlinked to stdout/stderr. - error_log /dev/stderr; - access_log /dev/stdout main; -} diff --git a/.editorconfig b/.editorconfig index e3689dd44..d72d7ace3 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,4 +1,4 @@ -# Drupal editor configuration normalization +# Editor configuration normalization # @see http://editorconfig.org/ # This is the top-most .editorconfig file; do not search in parent directories. @@ -8,13 +8,13 @@ root = true [*] end_of_line = LF indent_style = space -indent_size = 4 +indent_size = 2 charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true -[*.{js,scss}] -indent_size = 2 +[config/**.{yml,yaml}] +indent_size = 4 -[docker-compose*.yml] -indent_size = 2 +[*.php] +indent_size = 4 diff --git a/.env b/.env index 3e94f3020..e25463ff2 100644 --- a/.env +++ b/.env @@ -1,5 +1,6 @@ -COMPOSE_PROJECT_NAME=displayapiservice -COMPOSE_DOMAIN=displayapiservice.local.itkdev.dk +COMPOSE_PROJECT_NAME=display +COMPOSE_DOMAIN=display.local.itkdev.dk +ITKDEV_TEMPLATE=symfony-6 # In all environments, the following files are loaded if they exist, # the latter taking precedence over the former: @@ -108,3 +109,24 @@ EVENTDATABASE_API_V2_CACHE_EXPIRE_SECONDS=300 TRACK_SCREEN_INFO=false TRACK_SCREEN_INFO_UPDATE_INTERVAL_SECONDS=300 + +###> Admin configuration ### +### Will be injected in the html of the admin page. +ADMIN_REJSEPLANEN_APIKEY= +ADMIN_SHOW_SCREEN_STATUS=false +ADMIN_TOUCH_BUTTON_REGIONS=false +ADMIN_LOGIN_METHODS="[]" +ADMIN_ENHANCED_PREVIEW=false +###< Admin configuration ### + +###> Client configuration ### +### Will be injected in the html of the client page. +CLIENT_LOGIN_CHECK_TIMEOUT=20000 +CLIENT_REFRESH_TOKEN_TIMEOUT=300000 +CLIENT_RELEASE_TIMESTAMP_INTERVAL_TIMEOUT=600000 +CLIENT_SCHEDULING_INTERVAL=60000 +CLIENT_PULL_STRATEGY_INTERVAL=90000 +CLIENT_COLOR_SCHEME='{"type":"library","lat":56.0,"lng":10.0}' +CLIENT_DEBUG=false +CLIENT_LOGGING='[]' +###< Client configuration ### diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index dc5b4aec3..392c92657 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,6 +1,6 @@ -#### Link to ticket +#### Link to issue -Please add a link to the ticket being addressed by this change. +Please add a link to the issue being addressed by this change. #### Description @@ -17,7 +17,7 @@ If your change affects the user interface you should include a screenshot of the - [ ] My code passes our static analysis suite. - [ ] My code passes our continuous integration process. -If your code does not pass all the requirements on the checklist you have to add a comment explaining why this change +If your code does not pass all the requirements on the checklist you have to add a comment explaining why this change should be exempt from the list. #### Additional comments or questions diff --git a/.github/workflows/changelog.yaml b/.github/workflows/changelog.yaml new file mode 100644 index 000000000..483da6e95 --- /dev/null +++ b/.github/workflows/changelog.yaml @@ -0,0 +1,29 @@ +# Do not edit this file! Make a pull request on changing +# github/workflows/changelog.yaml in +# https://github.com/itk-dev/devops_itkdev-docker if need be. + +### ### Changelog +### +### Checks that changelog has been updated + +name: Changelog + +on: + pull_request: + +jobs: + changelog: + runs-on: ubuntu-latest + strategy: + fail-fast: false + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Git fetch + run: git fetch + + - name: Check that changelog has been updated. + run: git diff --exit-code origin/${{ github.base_ref }} -- CHANGELOG.md && exit 1 || exit 0 diff --git a/.github/workflows/composer.yaml b/.github/workflows/composer.yaml new file mode 100644 index 000000000..1bdcc5716 --- /dev/null +++ b/.github/workflows/composer.yaml @@ -0,0 +1,69 @@ +# Do not edit this file! Make a pull request on changing +# github/workflows/composer.yaml in +# https://github.com/itk-dev/devops_itkdev-docker if need be. + +### ### Composer +### +### Validates composer.json and checks that it's normalized. +### +### #### Assumptions +### +### 1. A docker compose service named `phpfpm` can be run and `composer` can be +### run inside the `phpfpm` service. +### 2. [ergebnis/composer-normalize](https://github.com/ergebnis/composer-normalize) +### is a dev requirement in `composer.json`: +### +### ``` shell +### docker compose run --rm phpfpm composer require --dev ergebnis/composer-normalize +### ``` +### +### Normalize `composer.json` by running +### +### ``` shell +### docker compose run --rm phpfpm composer normalize +### ``` + +name: Composer + +env: + COMPOSE_USER: root + +on: + pull_request: + push: + branches: + - main + - develop + +jobs: + composer-validate: + runs-on: ubuntu-latest + strategy: + fail-fast: false + steps: + - uses: actions/checkout@v4 + - run: | + docker network create frontend + docker compose run --rm phpfpm composer validate --strict + + composer-normalized: + runs-on: ubuntu-latest + strategy: + fail-fast: false + steps: + - uses: actions/checkout@v4 + - run: | + docker network create frontend + docker compose run --rm phpfpm composer install + docker compose run --rm phpfpm composer normalize --dry-run + + composer-audit: + runs-on: ubuntu-latest + strategy: + fail-fast: false + steps: + - uses: actions/checkout@v4 + - run: | + docker network create frontend + docker compose run --rm phpfpm composer install + docker compose run --rm phpfpm composer audit diff --git a/.github/workflows/github_build_release.yml b/.github/workflows/github_build_release.yml index 737d7f025..2ca6b44d7 100644 --- a/.github/workflows/github_build_release.yml +++ b/.github/workflows/github_build_release.yml @@ -1,7 +1,7 @@ on: push: tags: - - '*.*.*' + - "*.*.*" name: Create Github Release diff --git a/.github/workflows/itkdev_docker_build_develop.yml b/.github/workflows/itkdev_docker_build_develop.yml index 55667a326..aaef33621 100644 --- a/.github/workflows/itkdev_docker_build_develop.yml +++ b/.github/workflows/itkdev_docker_build_develop.yml @@ -2,7 +2,7 @@ on: push: branches: - - 'develop' + - "develop" # This Action builds to itkdev/* using ./infrastructure/itkdev/* name: ITK Dev - Build docker image (develop) diff --git a/.github/workflows/itkdev_docker_build_tag.yml b/.github/workflows/itkdev_docker_build_tag.yml index 0f5529a3c..f64644a28 100644 --- a/.github/workflows/itkdev_docker_build_tag.yml +++ b/.github/workflows/itkdev_docker_build_tag.yml @@ -2,7 +2,7 @@ on: push: tags: - - '*' + - "*" # This Action builds to itkdev/* using ./infrastructure/itkdev/* name: ITK Dev - Build docker image (tag) diff --git a/.github/workflows/javascript.yaml b/.github/workflows/javascript.yaml new file mode 100644 index 000000000..5b20c8da8 --- /dev/null +++ b/.github/workflows/javascript.yaml @@ -0,0 +1,36 @@ +# Do not edit this file! Make a pull request on changing +# github/workflows/symfony/javascript.yaml in +# https://github.com/itk-dev/devops_itkdev-docker if need be. + +### ### Symfony JavaScript (and TypeScript) +### +### Validates JavaScript files. +### +### #### Assumptions +### +### 1. A docker compose service named `prettier` for running +### [Prettier](https://prettier.io/) exists. + +name: JavaScript + +on: + pull_request: + push: + branches: + - main + - develop + +jobs: + javascript-lint: + runs-on: ubuntu-latest + strategy: + fail-fast: false + steps: + - name: Checkout + uses: actions/checkout@v4 + + - run: | + docker network create frontend + + - run: | + docker compose run --rm prettier 'assets/**/*.js' --check diff --git a/.github/workflows/markdown.yaml b/.github/workflows/markdown.yaml new file mode 100644 index 000000000..60fc0ee5c --- /dev/null +++ b/.github/workflows/markdown.yaml @@ -0,0 +1,43 @@ +# Do not edit this file! Make a pull request on changing +# github/workflows/markdown.yaml in +# https://github.com/itk-dev/devops_itkdev-docker if need be. + +### ### Markdown +### +### Lints Markdown files (`**/*.md`) in the project. +### +### [markdownlint-cli configuration +### files](https://github.com/igorshubovych/markdownlint-cli?tab=readme-ov-file#configuration), +### `.markdownlint.jsonc` and `.markdownlintignore`, control what is actually +### linted and how. +### +### #### Assumptions +### +### 1. A docker compose service named `markdownlint` for running `markdownlint` +### (from +### [markdownlint-cli](https://github.com/igorshubovych/markdownlint-cli)) +### exists. + +name: Markdown + +on: + pull_request: + push: + branches: + - main + - develop + +jobs: + markdown-lint: + runs-on: ubuntu-latest + strategy: + fail-fast: false + steps: + - name: Checkout + uses: actions/checkout@v4 + + - run: | + docker network create frontend + + - run: | + docker compose run --rm markdownlint markdownlint '**/*.md' diff --git a/.github/workflows/os2display_docker_build_develop.yml b/.github/workflows/os2display_docker_build_develop.yml index 613f4ef4c..bcdf09a6c 100644 --- a/.github/workflows/os2display_docker_build_develop.yml +++ b/.github/workflows/os2display_docker_build_develop.yml @@ -2,7 +2,7 @@ on: push: branches: - - 'develop' + - "develop" # This Action builds to os2display/* using ./infrastructure/os2display/* name: OS2display - Build docker image (develop) diff --git a/.github/workflows/os2display_docker_build_tag.yml b/.github/workflows/os2display_docker_build_tag.yml index 99f5fa098..8bd2b12aa 100644 --- a/.github/workflows/os2display_docker_build_tag.yml +++ b/.github/workflows/os2display_docker_build_tag.yml @@ -2,7 +2,7 @@ on: push: tags: - - '*' + - "*" # This Action builds to os2display/* using ./infrastructure/os2display/* name: OS2display - Build docker image (tag) diff --git a/.github/workflows/php.yaml b/.github/workflows/php.yaml new file mode 100644 index 000000000..60fb70eb3 --- /dev/null +++ b/.github/workflows/php.yaml @@ -0,0 +1,56 @@ +# Do not edit this file! Make a pull request on changing +# github/workflows/symfony/php.yaml in +# https://github.com/itk-dev/devops_itkdev-docker if need be. + +### ### Symfony PHP +### +### Checks that PHP code adheres to the [Symfony coding +### standards](https://symfony.com/doc/current/contributing/code/standards.html). +### +### #### Assumptions +### +### 1. A docker compose service named `phpfpm` can be run and `composer` can be +### run inside the `phpfpm` service. 2. +### [friendsofphp/php-cs-fixer](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer) +### is a dev requirement in `composer.json`: +### +### ``` shell +### docker compose run --rm phpfpm composer require --dev friendsofphp/php-cs-fixer +### ``` +### +### Clean up and check code by running +### +### ``` shell +### docker compose run --rm phpfpm vendor/bin/php-cs-fixer fix +### docker compose run --rm phpfpm vendor/bin/php-cs-fixer fix --dry-run --diff +### ``` +### +### > [!NOTE] The template adds `.php-cs-fixer.dist.php` as [a configuration +### > file for PHP CS +### > Fixer](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/doc/config.rst) +### > and this makes it possible to override the actual configuration used in a +### > project by adding a more important configuration file, `.php-cs-fixer.php`. + +name: Symfony PHP + +env: + COMPOSE_USER: root + +on: + pull_request: + push: + branches: + - main + - develop + +jobs: + coding-standards: + name: PHP - Check Coding Standards + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: | + docker network create frontend + docker compose run --rm phpfpm composer install + # https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/doc/usage.rst#the-check-command + docker compose run --rm phpfpm vendor/bin/php-cs-fixer fix --dry-run --diff diff --git a/.github/workflows/php_upgrade.yaml b/.github/workflows/php_upgrade.yaml deleted file mode 100644 index 0c99eec6d..000000000 --- a/.github/workflows/php_upgrade.yaml +++ /dev/null @@ -1,150 +0,0 @@ -on: pull_request -name: PHP Upgrade Check -jobs: - test-composer-install: - runs-on: ubuntu-latest - env: - COMPOSER_ALLOW_SUPERUSER: 1 - strategy: - fail-fast: false - matrix: - php: ["8.4"] - name: Validate composer (PHP ${{ matrix.php}}) - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup PHP, with composer and extensions - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php}} - extensions: apcu, ctype, iconv, imagick, json, redis, soap, xmlreader, zip - coverage: none - - - name: Get composer cache directory - id: composer-cache - run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - - - name: Cache composer dependencies - uses: actions/cache@v4 - with: - path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ matrix.php }}-composer-${{ hashFiles('**/composer.lock') }} - restore-keys: ${{ matrix.php }}-composer- - - - name: Validate composer files - run: composer validate composer.json --strict - - - name: '[prod] Composer install with exported .env variables' - run: | - set -a && source .env && set +a - APP_ENV=prod composer install --no-dev -o --ignore-platform-reqs - - - name: Reset composer install - run: rm -rf ./vendor - - - name: '[dev] Composer install with exported .env variables' - run: | - set -a && source .env && set +a - APP_ENV=dev composer install --ignore-platform-reqs - - - name: Normalize composer files - run: composer normalize --dry-run - - phpunit: - runs-on: ubuntu-latest - services: - mariadb: - image: mariadb:lts - ports: - - 3306 - env: - MYSQL_USER: db - MYSQL_PASSWORD: db - MYSQL_DATABASE: db_test - MYSQL_ROOT_PASSWORD: password - # https://mariadb.org/mariadb-server-docker-official-images-healthcheck-without-mysqladmin/ - options: >- - --health-cmd="healthcheck.sh --connect --innodb_initialized" - --health-interval=5s - --health-timeout=2s - --health-retries=3 - strategy: - fail-fast: false - matrix: - php: ["8.4"] - name: PHP Unit tests (PHP ${{ matrix.php }}) - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup PHP, with composer and extensions - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php}} - extensions: apcu, ctype, iconv, imagick, json, redis, soap, xmlreader, zip - coverage: none - - - name: Get composer cache directory - id: composer-cache - run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - - - name: Cache composer dependencies - uses: actions/cache@v4 - with: - path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ matrix.php }}-composer-${{ hashFiles('**/composer.lock') }} - restore-keys: ${{ matrix.php }}-composer- - - - name: Install Dependencies - run: composer install -q --no-ansi --no-interaction --no-scripts --no-suggest --no-progress --prefer-dist --ignore-platform-reqs - - - name: PHP Unit - Test setup - env: - PORT: ${{ job.services.mariadb.ports[3306] }} - run: DATABASE_URL="mysql://db:db@127.0.0.1:$PORT/db_test" composer run test-setup - - - name: PHP Unit - Test - env: - PORT: ${{ job.services.mariadb.ports[3306] }} - run: DATABASE_URL="mysql://db:db@127.0.0.1:$PORT/db_test" composer run test - - apispec: - runs-on: ubuntu-latest - name: API Specification validation - strategy: - fail-fast: false - matrix: - php: ["8.4"] - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 2 - - - name: Setup PHP, with composer and extensions - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php}} - extensions: apcu, ctype, iconv, imagick, json, redis, soap, xmlreader, zip - coverage: none - - - name: Get composer cache directory - id: composer-cache - run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - - - name: Cache composer dependencies - uses: actions/cache@v4 - with: - path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ matrix.php }}-composer-${{ hashFiles('**/composer.lock') }} - restore-keys: ${{ matrix.php }}-composer- - - - name: Install Dependencies - run: composer install -q --no-ansi --no-interaction --no-scripts --no-suggest --no-progress --prefer-dist --ignore-platform-reqs - - - name: Export specifications - run: bin/console api:openapi:export --yaml --output=public/api-spec-v2.yaml --no-interaction - - - name: Check for changes in specifications - run: git diff --diff-filter=ACMRT --exit-code public/api-spec-v2.yaml diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index ca503f0a2..87882f189 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -32,25 +32,11 @@ jobs: key: ${{ matrix.php }}-composer-${{ hashFiles('**/composer.lock') }} restore-keys: ${{ matrix.php }}-composer- - - name: Validate composer files - run: composer validate composer.json --strict - - - name: '[prod] Composer install with exported .env variables' + - name: "[prod] Composer install with exported .env variables" run: | set -a && source .env && set +a APP_ENV=prod composer install --no-dev -o - - name: Reset composer install - run: rm -rf ./vendor - - - name: '[dev] Composer install with exported .env variables' - run: | - set -a && source .env && set +a - APP_ENV=dev composer install - - - name: Normalize composer files - run: composer normalize --dry-run - validate-doctrine-shema: runs-on: ubuntu-latest env: @@ -93,7 +79,7 @@ jobs: key: ${{ matrix.php }}-composer-${{ hashFiles('**/composer.lock') }} restore-keys: ${{ matrix.php }}-composer- - - name: 'Composer install with exported .env variables' + - name: "Composer install with exported .env variables" run: | set -a && source .env && set +a APP_ENV=prod composer install --no-dev -o @@ -104,41 +90,6 @@ jobs: - name: Validate Doctrine schema run: APP_ENV=prod php bin/console doctrine:schema:validate - php-cs-fixer: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - php: ["8.3"] - name: PHP Coding Standards Fixer (PHP ${{ matrix.php }}) - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup PHP, with composer and extensions - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php}} - extensions: apcu, ctype, iconv, imagick, json, redis, soap, xmlreader, zip - coverage: none - - - name: Get composer cache directory - id: composer-cache - run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - - - name: Cache composer dependencies - uses: actions/cache@v4 - with: - path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ matrix.php }}-composer-${{ hashFiles('**/composer.lock') }} - restore-keys: ${{ matrix.php }}-composer- - - - name: Install Dependencies - run: composer install -q --no-ansi --no-interaction --no-scripts --no-suggest --no-progress --prefer-dist - - - name: php-cs-fixer - run: phpdbg -qrr ./vendor/bin/php-cs-fixer fix --dry-run - psalm: runs-on: ubuntu-latest strategy: @@ -267,23 +218,6 @@ jobs: PORT: ${{ job.services.mariadb.ports[3306] }} run: DATABASE_URL="mysql://db:db@127.0.0.1:$PORT/db_test" composer run test - markdownlint: - runs-on: ubuntu-latest - name: markdownlint - strategy: - fail-fast: false - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 2 - - - name: Install - run: docker run --rm -v .:/app --workdir=/app node:18 npm install - - - name: Markdown lint - run: docker run --rm -v .:/app --workdir=/app node:18 npm run coding-standards-check - apispec: runs-on: ubuntu-latest name: API Specification validation @@ -329,34 +263,3 @@ jobs: - name: Check for changes in specifications (json) run: git diff --diff-filter=ACMRT --exit-code public/api-spec-v2.json - - changelog: - runs-on: ubuntu-latest - name: Changelog should be updated - strategy: - fail-fast: false - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 2 - - - name: Git fetch - run: git fetch - - - name: Check that changelog has been updated. - run: git diff --exit-code origin/${{ github.base_ref }} -- CHANGELOG.md && exit 1 || exit 0 - - yamllint-api-resources: - runs-on: ubuntu-latest - name: yamllint (API resources) - strategy: - fail-fast: false - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 2 - - - name: Check API resources YAML - run: docker run --volume ${PWD}:/code --rm pipelinecomponents/yamllint yamllint config/api_platform diff --git a/.github/workflows/styles.yaml b/.github/workflows/styles.yaml new file mode 100644 index 000000000..edc796020 --- /dev/null +++ b/.github/workflows/styles.yaml @@ -0,0 +1,36 @@ +# Do not edit this file! Make a pull request on changing +# github/workflows/symfony/styles.yaml in +# https://github.com/itk-dev/devops_itkdev-docker if need be. + +### ### Symfony Styles (CSS and SCSS) +### +### Validates styles files. +### +### #### Assumptions +### +### 1. A docker compose service named `prettier` for running +### [Prettier](https://prettier.io/) exists. + +name: Styles + +on: + pull_request: + push: + branches: + - main + - develop + +jobs: + styles-lint: + runs-on: ubuntu-latest + strategy: + fail-fast: false + steps: + - name: Checkout + uses: actions/checkout@v4 + + - run: | + docker network create frontend + + - run: | + docker compose run --rm prettier 'assets/**/*.{css,scss}' --check diff --git a/.github/workflows/twig.yaml b/.github/workflows/twig.yaml new file mode 100644 index 000000000..9b0e34314 --- /dev/null +++ b/.github/workflows/twig.yaml @@ -0,0 +1,48 @@ +# Do not edit this file! Make a pull request on changing +# github/workflows/twig.yaml in +# https://github.com/itk-dev/devops_itkdev-docker if need be. + +### ### Twig +### +### Validates Twig files +### +### #### Assumptions +### +### 1. A docker compose service named `phpfpm` can be run and `composer` can be +### run inside the `phpfpm` service. +### 2. [vincentlanglet/twig-cs-fixer](https://github.com/VincentLanglet/Twig-CS-Fixer) +### is a dev requirement in `composer.json`: +### +### ``` shell +### docker compose run --rm phpfpm composer require --dev vincentlanglet/twig-cs-fixer +### ``` +### +### 3. A [Configuration +### file](https://github.com/VincentLanglet/Twig-CS-Fixer/blob/main/docs/configuration.md#configuration-file) +### in the root of the project defines which files to check and rules to use. + +name: Twig + +env: + COMPOSE_USER: root + +on: + pull_request: + push: + branches: + - main + - develop + +jobs: + twig-lint: + runs-on: ubuntu-latest + strategy: + fail-fast: false + steps: + - name: Checkout + uses: actions/checkout@v4 + + - run: | + docker network create frontend + docker compose run --rm phpfpm composer install + docker compose run --rm phpfpm vendor/bin/twig-cs-fixer lint diff --git a/.github/workflows/yaml.yaml b/.github/workflows/yaml.yaml new file mode 100644 index 000000000..1c0ada3f7 --- /dev/null +++ b/.github/workflows/yaml.yaml @@ -0,0 +1,40 @@ +# Do not edit this file! Make a pull request on changing +# github/workflows/yaml.yaml in +# https://github.com/itk-dev/devops_itkdev-docker if need be. + +### ### YAML +### +### Validates YAML files. +### +### #### Assumptions +### +### 1. A docker compose service named `prettier` for running +### [Prettier](https://prettier.io/) exists. +### +### #### Symfony YAML +### +### Symfony's YAML config files use 4 spaces for indentation and single quotes. +### Therefore we use a [Prettier configuration +### file](https://prettier.io/docs/configuration), `.prettierrc.yaml`, to make +### Prettier format YAML files in the `config/` folder like Symfony expects. + +name: YAML + +on: + pull_request: + push: + branches: + - main + - develop + +jobs: + yaml-lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - run: | + docker network create frontend + + - run: | + docker compose run --rm prettier '**/*.{yml,yaml}' --check diff --git a/.gitignore b/.gitignore index 2d3adbc34..573966ad8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ +/repos + +/public/admin +/public/client +/public/release.json + ###> symfony/framework-bundle ### /.env.local /.env.local.php @@ -38,3 +44,19 @@ launch.json ###> phpstan/phpstan ### phpstan.neon ###< phpstan/phpstan ### + +###> pentatrion/vite-bundle ### +/node_modules/ +/public/build/ +###< pentatrion/vite-bundle ### + +#> Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +#< Playwright + +###> vincentlanglet/twig-cs-fixer ### +/.twig-cs-fixer.cache +###< vincentlanglet/twig-cs-fixer ### diff --git a/.markdownlint.json b/.markdownlint.json deleted file mode 100644 index cd15f2224..000000000 --- a/.markdownlint.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "default": true, - "MD013": { - "line_length": 120, - "code_blocks": false, - "tables": false - }, - "no-duplicate-heading": { - "siblings_only": true - } -} diff --git a/.markdownlint.jsonc b/.markdownlint.jsonc new file mode 100644 index 000000000..025309654 --- /dev/null +++ b/.markdownlint.jsonc @@ -0,0 +1,22 @@ +// This file is copied from config/markdown/.markdownlint.jsonc in https://github.com/itk-dev/devops_itkdev-docker. +// Feel free to edit the file, but consider making a pull request if you find a general issue with the file. + +// markdownlint-cli configuration file (cf. https://github.com/igorshubovych/markdownlint-cli?tab=readme-ov-file#configuration) +{ + "default": true, + // https://github.com/DavidAnson/markdownlint/blob/main/doc/md013.md + "line-length": { + "line_length": 120, + "code_blocks": false, + "tables": false + }, + // https://github.com/DavidAnson/markdownlint/blob/main/doc/md024.md + "no-duplicate-heading": { + "siblings_only": true + }, + // https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/organizing-information-with-collapsed-sections#creating-a-collapsed-section + // https://github.com/DavidAnson/markdownlint/blob/main/doc/md033.md + "no-inline-html": { + "allowed_elements": ["details", "summary"] + } +} diff --git a/.markdownlintignore b/.markdownlintignore new file mode 100644 index 000000000..a4bd9bdd5 --- /dev/null +++ b/.markdownlintignore @@ -0,0 +1,6 @@ +# This file is copied from config/markdown/.markdownlintignore in https://github.com/itk-dev/devops_itkdev-docker. +# Feel free to edit the file, but consider making a pull request if you find a general issue with the file. + +# https://github.com/igorshubovych/markdownlint-cli?tab=readme-ov-file#ignoring-files +vendor/ +node_modules/ diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 1367a2a4c..dcb943dd6 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -1,25 +1,33 @@ in(__DIR__) - ->exclude('var') -; +$finder = new PhpCsFixer\Finder(); +// Check all files … +$finder->in(__DIR__); +// … that are not ignored by VCS +$finder->ignoreVCSIgnored(true); + +$config = new PhpCsFixer\Config(); +$config->setFinder($finder); + +$config->registerCustomFixers(new PhpCsFixerCustomFixers\Fixers()); +$config->setRiskyAllowed(true); + +$config->setRules([ + '@Symfony' => true, + 'phpdoc_align' => false, + 'no_superfluous_phpdoc_tags' => false, + 'array_syntax' => ['syntax' => 'short'], + 'phpdoc_to_comment' => false, + 'declare_strict_types' => true, + ConstructorEmptyBracesFixer::name() => true, + MultilinePromotedPropertiesFixer::name() => true, +]); -return (new PhpCsFixer\Config()) - ->registerCustomFixers(new PhpCsFixerCustomFixers\Fixers()) - ->setRiskyAllowed(true) - ->setRules([ - '@Symfony' => true, - 'phpdoc_align' => false, - 'no_superfluous_phpdoc_tags' => false, - 'array_syntax' => ['syntax' => 'short'], - 'phpdoc_to_comment' => false, - 'declare_strict_types' => true, - ConstructorEmptyBracesFixer::name() => true, - MultilinePromotedPropertiesFixer::name() => true, - ]) - ->setFinder($finder) -; +return $config; diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..d3e47187f --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +public/api-spec-v2.yaml diff --git a/.prettierrc.yaml b/.prettierrc.yaml new file mode 100644 index 000000000..12e08983f --- /dev/null +++ b/.prettierrc.yaml @@ -0,0 +1,11 @@ +# This file is copied from config/symfony/yaml/.prettierrc.yaml in https://github.com/itk-dev/devops_itkdev-docker. +# Feel free to edit the file, but consider making a pull request if you find a general issue with the file. + +# https://prettier.io/docs/configuration +overrides: + # Symfony config + - files: + - "config/**/*.{yml,yaml}" + options: + tabWidth: 4 + singleQuote: true diff --git a/.twig-cs-fixer.dist.php b/.twig-cs-fixer.dist.php new file mode 100644 index 000000000..82425550b --- /dev/null +++ b/.twig-cs-fixer.dist.php @@ -0,0 +1,16 @@ +in(__DIR__); +// … that are not ignored by VCS +$finder->ignoreVCSIgnored(true); + +$config = new TwigCsFixer\Config\Config(); +$config->setFinder($finder); + +return $config; diff --git a/.yamllint.yaml b/.yamllint.yaml deleted file mode 100644 index 8d253ac0c..000000000 --- a/.yamllint.yaml +++ /dev/null @@ -1,44 +0,0 @@ -# https://yamllint.readthedocs.io/en/stable/configuration.html#configuration -# docker run --volume $(pwd):/code --rm pipelinecomponents/yamllint yamllint config/api_platform ---- - -yaml-files: - - '*.yaml' - -ignore: - - node_modules/ - - vendor/ - -extends: default - -# https://yamllint.readthedocs.io/en/stable/rules.html -rules: - # anchors: enable - # braces: enable - # brackets: enable - # colons: enable - # commas: enable - # comments: - # level: warning - # comments-indentation: - # level: warning - # document-end: disable - # document-start: - # level: warning - # empty-lines: enable - # empty-values: disable - # float-values: disable - # hyphens: enable - indentation: - spaces: 4 - # key-duplicates: enable - # key-ordering: disable - line-length: - max: 120 - # new-line-at-end-of-file: enable - # new-lines: enable - # octal-values: disable - # quoted-strings: disable - # trailing-spaces: enable - # truthy: - # level: warning diff --git a/CHANGELOG.md b/CHANGELOG.md index 784e7a696..91ad6d864 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,370 +4,18 @@ All notable changes to this project will be documented in this file. ## [Unreleased] -## [2.5.1] - 2025-06-23 +* Gathered all repositories in one Symfony application. +* Changed to vite 7 and rolldown. +* Added ADRs 008 and 009. +* Cleaned up Github Actions workflows. +* Updated PHP dependencies. -- [#245](https://github.com/os2display/display-api-service/pull/245) - - Added resource endpoint for limiting access to instant book interactive slide. -- [#243](https://github.com/os2display/display-api-service/pull/243) - - Changed resource name in calendar api feed type resource selector. -- [#242](https://github.com/os2display/display-api-service/pull/242) - - Changed calendar api feed type to use display name from resources feed instead of events feed. +### NB! Prior to 3.x the project was split into separate repositories -## [2.5.0] - 2025-05-09 +Therefore, changelogs were maintained for each repo. These are available here: -- [#240](https://github.com/os2display/display-api-service/pull/240) - - Fixed issues with Colibo feed type. -- [#226](https://github.com/os2display/display-api-service/pull/226) - - Added Colibo feed type. - -## [2.4.0] - 2025-03-31 - -- [#238](https://github.com/os2display/display-api-service/pull/238) - - Added screen status to infrastructure. -- [#232](https://github.com/os2display/display-api-service/pull/232) - - Updated user emails in readme. -- [#227](https://github.com/os2display/display-api-service/pull/227) - - Added screen status to cache and endpoint for exposing screen status. - -## [2.3.0] - 2025-03-24 - -- [#235](https://github.com/os2display/display-api-service/pull/234) - - Fixed Eventdatabasen v2 subscription data order by using occurrences endpoint. -- [#236](https://github.com/os2display/display-api-service/pull/236) - - Fixed bug where no media url made Notified feed crash. -- [#231](https://github.com/os2display/display-api-service/pull/231) - - Adds new feed source: Eventdatabasen v2. -- [#233](https://github.com/os2display/display-api-service/pull/233) - - Added calendar api feed source tests for modifiers. - - Changed to use PCRE pattern instead of custom pattern building and fixed modifier bugs for calendar api feed source. - -## [2.2.0] - 2025-03-17 - -- [#229](https://github.com/os2display/display-api-service/pull/229) - - Adds options to set paths to component and admin files from path to the json config file. -- [#225](https://github.com/os2display/display-api-service/pull/225) - - Added ADRs. -- [#215](https://github.com/os2display/display-api-service/pull/215) - - Added calendar api feed type. -- [#223](https://github.com/os2display/display-api-service/pull/223) - - Added explicit fixtures to avoid false negatives in the test suite -- [#219](https://github.com/os2display/display-api-service/pull/219) - - Fixed psalm, test, coding standards and updated api spec. -- [#222](https://github.com/os2display/display-api-service/pull/222) - - Adds create, update, delete operations to feed-source endpoint. - - Adds data validation for feed source. - -## [2.1.4] - 2025-01-14 - -- [#230](https://github.com/os2display/display-api-service/pull/230) - - Adds options to set paths to component and admin files from path to the json config file. - -## [2.1.3] - 2024-10-25 - -- [#220](https://github.com/os2display/display-api-service/pull/220) - - Fixed issue where saving a screen changed all regions with same ID. - -## [2.1.2] - 2024-10-24 - -- [#213](https://github.com/os2display/display-api-service/pull/213) - - Set `phpdoc_to_comment` to `false`in `.php-cs-fixer.dist.php` to avoid breaking psalm ignore - - Add regions and groups to `ScreenInput.php` - - Add "cascade: persist remove" to PlaylistScreenRegion - - Save playlist/regions in `ScreenProcessor.php` and in `src/entity/ScreenLayoutRegions` (as an alternative to sending - multiple requests) - - Save groups in `ScreenProcessor.php` and in `src/entity/tenant/Screen.php` - - Update psalm baseline - - Add regions/playlists and groups to POST screen test - - `composer update symfony/* --with-dependencies` - -## [2.1.1] - 2024-10-23 - -- [#217](https://github.com/os2display/display-api-service/pull/217) - - Update composer dependencies to fix redis error - -## [2.1.0] - 2024-10-23 - -- [#214](https://github.com/os2display/display-api-service/pull/214) - - Updated endSessionUrl to be nullable. - -- [#193](https://github.com/os2display/display-api-service/pull/193) - - Adds support for interactive slides. - - Adds interactivity for creating quick bookings from a slide through Microsoft Graph. - - Adds KeyVaultService that can serve key-value entries from the environment for storing secrets. - -## [2.0.7] - 2024-08-20 - -- [#211](https://github.com/os2display/display-api-service/pull/211) - - Fixed sql error in relations modified listener - -## [2.0.6] - 2024-06-28 - -- [#208](https://github.com/os2display/display-api-service/pull/208) - - Removed feed items from Notified where image returns 403. - - Fixed phpunit github actions healthcheck for mariadb. -- [#207](https://github.com/os2display/display-api-service/pull/207) - - Fixed parameter not set error in (os2display) api container. - -## [2.0.5] - 2024-05-21 - -- [#206](https://github.com/os2display/display-api-service/pull/206) - - Added support for Notified (Instagram) feed as replacement for SparkleIOFeedType. - - Deprecated SparkleIOFeedType. (getsparkle.io has shut down) - -## [2.0.4] - 2024-04-25 - -- [#204](https://github.com/os2display/display-api-service/pull/204) - - Ensured real ip is logged in nginx. -- [#200](https://github.com/os2display/display-api-service/pull/200) - - Updated oidc internal documentation. -- [#205](https://github.com/os2display/display-api-service/pull/205) - - Fixed redirecting post requests. - -## [2.0.3] - 2024-04-10 - -- [#203](https://github.com/os2display/display-api-service/pull/203) - - Changed theme->addLogo() to theme->setLogo(). - -## [2.0.2] - 2024-04-10 - -- [#202](https://github.com/os2display/display-api-service/pull/202) - - Fixed ScreenUser blamable identifier. - -## [2.0.1] - 2024-04-09 - -- [#201](https://github.com/os2display/display-api-service/pull/201) - - Add /v1 - /v2 redirect controller - -## [2.0.0] - 2024-04-09 - -- [#199](https://github.com/os2display/display-api-service/pull/199) - - Add doctrine migration to change media references from API 'v1' to API 'v2' in slide content -- [#198](https://github.com/os2display/display-api-service/pull/198) - - Changed route prefix to v2. -- [#197](https://github.com/os2display/display-api-service/pull/197) - - Fixed weight issue when assigning slides to playlist. -- [#194](https://github.com/os2display/display-api-service/pull/194) Updated test run documentation and added test for - `rrule` in playlist. -- Fixed issue with PlaylistSlide transaction. -- Fixed issues with feed following api platform upgrade. -- [#192](https://github.com/os2display/display-api-service/pull/192) - - Fix env value typo's -- [#191](https://github.com/os2display/display-api-service/pull/191) - - Add logging for OIDC errors -- [#189](https://github.com/os2display/display-api-service/pull/189) - - Updated and applied psalm and rector settings - - Added psalm and rector to PR check on github actions -- [#188](https://github.com/os2display/display-api-service/pull/188) - - Update build images to PHP 8.3 - - Update to Symfony 6.4 LTS with dependencies - - Update Github Actions to latest versions - - Refactor "tenant" injection in repositories -- [#186](https://github.com/os2display/display-api-service/pull/186) - - Fix for "relations modified" not set correctly on OneToMany relations -- [#185](https://github.com/os2display/display-api-service/pull/185) - - Disable RelationsModified listener when loading fixtures to optimize performance -- [#184](https://github.com/os2display/display-api-service/pull/184) - - Added RelationsModifiedTrait to serialization groups. -- [#182](https://github.com/os2display/display-api-service/pull/182) - - Changed "Theme" api output to have "Logo" embedded to avoid 404 errors when fetching logo from other shared slide w. - foreign tenant. -- [#181](https://github.com/os2display/display-api-service/pull/181) - - Update minimum PHP version to 8.2 to support trait constants - - Add 'relationsModified' timestamps on relevant entities and API resources. -- [#179](https://github.com/os2display/display-api-service/pull/179) - - Fixed how playlists are added/removed from slides. -- [#178](https://github.com/os2display/display-api-service/pull/178) - - Fixed issues with objects not being expanded in collections. -- [#176](https://github.com/os2display/display-api-service/pull/176) - - Fixed issues with objects not being expanded in collections. -- [#175](https://github.com/os2display/display-api-service/pull/175) - - Fixed issues with objects not being expanded in collections. -- [#174](https://github.com/os2display/display-api-service/pull/174) - - Update composer dependencies - - Update `symfony/flex` 1.x -> 2.x - - Update `vich/uploader-bundle` 1.x -> 2.x - - Update `debril/feed-io 5.x -> 6.x - - Enforce strict types - - Switch from doctrine annotations to attributes - - Add rector as dev dependency and apply rules - - Handle doctrine deprecations -- [#173](https://github.com/os2display/display-api-service/pull/173) Upgraded to API Platform 3 -- [#172](https://github.com/os2display/display-api-service/pull/172) Linted YAML API resources -- [#171](https://github.com/os2display/display-api-service/pull/171) Fixed slide playlists collection operation. -- [#170](https://github.com/os2display/display-api-service/pull/170) Updated Symfony development packages. -- [#165](https://github.com/os2display/display-api-service/pull/165) Symfony 6.3 -- [#162](https://github.com/os2display/display-api-service/pull/162) - - Adds "external" openid-connect provider. - - Renamed "oidc" openid-connect provider to "internal". - - Modifies User to support external user type. - - Adds command to set user type. - - Expands api with external user endpoints. - - Upgrades openid-connect bundle to 3.1 to support multiple providers. - - Changes php requirement in composer.json to >= 8.1. - - Removed PHP Upgrade coding standards github actions check. - - Changed user identifier from email to providerId. Made email nullable. Copied value from email to providerId in - migration. -- [#161](https://github.com/os2display/display-api-service/pull/161) Fixed non-entity related psalm errors. - -## [1.5.0] - 2023-10-26 - -- [#167](https://github.com/os2display/display-api-service/pull/167) - - Removed references to non-existing exception. -- [#166](https://github.com/os2display/display-api-service/pull/166) - - Wrapped feeds in try-catch to avoid throwing errors. - - Added unpublished flow to EventDatabase feed when occurrence returns 404. - - Fixed EventDatabase feed poster subscription parameters not being applied when calling getData(). -- [#163](https://github.com/os2display/display-api-service/pull/163) - - Upgraded `itk-dev/openid-connect-bundle` to use code authorization flow. Updated OpenAPI spec accordingly. - -## [1.4.0] - 2023-09-14 - -- [#160](https://github.com/os2display/display-api-service/pull/160) - - Added app:feed:list-feed-source command. Removed listing from app:feed:remove-feed-source command. -- [#159](https://github.com/os2display/display-api-service/pull/159) - - Fixed sprintf issue. -- [#158](https://github.com/os2display/display-api-service/pull/158) - - Added thumbnails for image resources - -## [1.3.2] - 2023-07-11 - -- [#157](https://github.com/os2display/display-api-service/pull/157) - - Fix question input on create user command - -## [1.3.1] - 2023-07-11 - -- [#156](https://github.com/os2display/display-api-service/pull/156) - - Fix permissions in create release github action - -## [1.3.0] - 2023-07-11 - -- [#155](https://github.com/os2display/display-api-service/pull/155) - - Set up separate image builds for itkdev and os2display -- [#154](https://github.com/os2display/display-api-service/pull/154) - - Updated add user command to ask which tenants user belongs to -- [#151](https://github.com/os2display/display-api-service/pull/151) - - Fixed feed data provider id issue -- [#150](https://github.com/os2display/display-api-service/pull/150) - - Update docker build to publish to "os2display" org on docker hub. - - Update github workflow to latest actions. -- [#148](https://github.com/os2display/display-api-service/pull/148) - - Updated `EventDatabaseApiFeedType` query ensuring started but not finished events are found. -- [#157](https://github.com/os2display/display-api-service/pull/157) - - Refactored all feed related classes and services - - Minor update of composer packages - - Updated psalm to version 5.x - -## [1.2.9] - 2023-06-30 - -- [#153](https://github.com/os2display/display-api-service/pull/153) - - Fixed nginx entry script - -## [1.2.8] - 2023-05-25 - -- [#145](https://github.com/os2display/display-api-service/pull/145) - - Gif mime type possible. - -## [1.2.7] - 2023-04-03 - -- [#143](https://github.com/os2display/display-api-service/pull/143) - - Fixed token ttl not set correctly for ScreenUsers -- [#142](https://github.com/os2display/display-api-service/pull/142) - - Make it possible to upload svg in api. - -## [1.2.6] - 2023-03-24 - -- [#141](https://github.com/os2display/display-api-service/pull/141) - - Readded redis to docker-compose. - -## [1.2.5] - 2023-03-16 - -- [#138](https://github.com/os2display/display-api-service/pull/138) - - Fixed Tenant and command to allow for empty fallbackImageUrl. -- [#139](https://github.com/os2display/display-api-service/pull/139) - - Changed from service decoration to event listeners to re-enable setting `tenants` on the response from - `/v1/authentication/token`. - - Ensure same response data from both `/v1/authentication/token` and `/v1/authentication/token/refresh`endpoints. - - Added `user` and `tenants` to JWT payload. - -## [1.2.4] - 2023-03-07 - -- [#133](https://github.com/os2display/display-api-service/pull/133) - - Adds upload size values to nginx config. -- [#137](https://github.com/os2display/display-api-service/pull/137) - - Default sorting for templates is by title - -## [1.2.3] - 2023-02-14 - -- [#136](https://github.com/os2display/display-api-service/pull/136) - - Updated to latest version of github actions -- [#134](https://github.com/os2display/display-api-service/pull/134) - - Fix bug where `JWT_SCREEN_REFRESH_TOKEN_TTL` value is not used when refresh token is renewed - -## [1.2.2] - 2023-02-08 - -- [#132](https://github.com/os2display/display-api-service/pull/132) - - Added `RefreshToken` entity to fix migrations error. -- [#135](https://github.com/os2display/display-api-service/pull/135) - - Updated code styles. - -## [1.2.1] - 2023-02-02 - -- Update composer packages, CVE-2022-24894, CVE-2022-24895 - -## [1.2.0] - 2023-01-05 - -- [#130](https://github.com/os2display/display-api-service/pull/130) - - Added changelog. - - Added github action to enforce that PRs should always include an update of the changelog. -- [#129](https://github.com/os2display/display-api-service/pull/129) - - Downgraded to Api Platform 2.6, since 2.7 introduced a change in serialization. Locking to 2.6.* -- [#127](https://github.com/os2display/display-api-service/pull/127) - - Updated docker setup and actions to PHP 8.1. - - Updated code style. -- [#128](https://github.com/os2display/display-api-service/pull/128) - - Added ttl_update: true config option for jwt refresh bundle. - - Added refresh_token_expiration key to respone body. -- [#124](https://github.com/os2display/display-api-service/pull/124) - - Created ThemeItemDataProvider instead of - - ThemeOutputDataTransformer, to make theme accessible in the client on shared slides. - - Made it possible for editors to view themes and connect them to slides: security: 'is_granted("ROLE_SCREEN") or -is_granted("ROLE_ADMIN") or is_granted("ROLE_EDITOR")'. -- [#126](https://github.com/os2display/display-api-service/pull/126) - - Added config option for setting token TTL for screen users. -- [#123](https://github.com/os2display/display-api-service/pull/123) - - Updated fixtures. -- [#125](https://github.com/os2display/display-api-service/pull/125) - - Changed error handling to not always return empty array even though it is only one resource that reports error. - - Added error logging. -- [#122](https://github.com/os2display/display-api-service/pull/122) - - Updated docker setup to match new itkdev base setup. -- [#121](https://github.com/os2display/display-api-service/pull/121) - - Changed load screen layout command to allow updating existing layouts. - -## [1.1.0] - 2022-09-29 - -- [#120](https://github.com/os2display/display-api-service/pull/120) - - Fixed path for shared Media. -- [#119](https://github.com/os2display/display-api-service/pull/119) - - KOBA feed source: Changed naming in resource options. Sorted options. - -## [1.0.4] - 2022-09-05 - -- [#117](https://github.com/os2display/display-api-service/pull/117) - - Removed screen width and height. Added resolution/orientation. - -## [1.0.3] - 2022-09-01 - -- Changed docker server setup. - -## [1.0.2] - 2022-09-01 - -- Changed docker server setup. - -## [1.0.1] - 2022-09-01 - -- Changed docker server setup. - -## [1.0.0] - 2022-05-18 - -- First release. +* API: [docs/changelogs/api.md](docs/changelogs/api.md) +* Admin: [docs/changelogs/admin.md](docs/changelogs/admin.md) +* Template: [docs/changelogs/template.md](docs/changelogs/template.md) +* Client: [docs/changelogs/client.md](docs/changelogs/client.md) +* Docs: [docs/changelogs/docs.md](docs/changelogs/docs.md) diff --git a/README.md b/README.md index e2d82128b..2bc0eb622 100644 --- a/README.md +++ b/README.md @@ -1,324 +1,28 @@ -# DisplayApi +# OS2Display -## OpenAPI specification +## Description -The OpenAPI specification is committed to this repo as `public/api-spec-v2.yaml` -and as `public/api-spec-v2.json`. +TODO -A CI check will compare the current API implementation to the spec. If they -are different the check will fail. - -If a PR makes _planned_ changes to the spec, the commited file must be updated: - -```shell -docker compose exec phpfpm composer update-api-spec -``` - -If these are _breaking_ changes the API version must be changed accordingly. - -## Stateless - -The API is stateless except `/v2/authentication` routes. -Make sure to set the `CORS_ALLOW_ORIGIN` correctly in `.env.local`. - -## Rest API & Relationships - -To avoid embedding all relations in REST representations but still allow the clients to minimize the amount of API calls -they have to make all endpoints that have relations also has a `relationsModified` field: - -```json - "@id": "/v2/screens/000XB4RQW418KK14AJ054W1FN2", - ... - "relationsModified": { - "campaigns": "cf9bb7d5fd04743dd21b5e3361db7eed575258e0", - "layout": "4dc925b9043b9d151607328ab2d022610583777f", - "regions": "278df93a0dc5309e0db357177352072d86da0d29", - "inScreenGroups": "bf0d49f6af71ac74da140e32243f3950219bb29c" - } -``` - -The checksums are based on `id`, `version` and `relationsModified` fields of the entity under that key in the -relationship tree. This ensures that any change in the bottom of the tree will propagate as changed checksums up the -tree. - -Updating `relationsModified` is handled in a `postFlush` event listener `App\EventListener\RelationsModifiedAtListener`. -The listener will execute a series of raw SQL statements starting from the bottom of the tree and progressing up. - -### Partial Class Diagram - -For reference a partial class diagram to illustrate the relevant relationships. - -```mermaid -classDiagram - class `Screen` - class `ScreenCampaign` - class `ScreenGroup` - class `ScreenGroupCampaign` - class `ScreenLayout` - class `ScreenLayoutRegions` - class `PlaylistScreenRegion` - class `Playlist` - class `Schedule` - class `PlaylistSlide` - class `Slide` - class `Template` - class `Theme` - class `Media` - class `Feed` - class `FeedSource` - Screen "1..*" -- "0..n" ScreenGroup - Screen "0..*" -- "1" ScreenLayout - Screen "1" -- "0..*" ScreenCampaign - ScreenLayout "1" -- "1..n" ScreenLayoutRegions - ScreenGroup "1" -- "1..n" ScreenGroupCampaign - Screen "1" -- "1..n" PlaylistScreenRegion - ScreenLayoutRegions "1" -- "1..n" PlaylistScreenRegion - ScreenCampaign "0..n" -- "1" Playlist - PlaylistScreenRegion "0..n" -- "1" Playlist - ScreenGroupCampaign "0..n" -- "1" Playlist - Playlist "1" -- "0..n" Schedule - Playlist "1" -- "0..n" PlaylistSlide - PlaylistSlide "0..n" -- "1" Slide - Slide "0..n" -- "1" Template - Slide "0..n" -- "1" Theme - Theme "0..n" -- "0..1" Media : Has logo - Slide "0..n" -- "0..n" Media : Has media - Slide "0..1" -- "0..1" Feed - Feed "0..n" -- "1" FeedSource -``` - -## Development Setup - -A `docker-compose.yml` file with a PHP 8.3 image is included in this project. -To install the dependencies you can run - -```shell -docker compose pull -docker compose up --detach -docker compose exec phpfpm composer install - -# Run migrations -docker compose exec phpfpm bin/console doctrine:migrations:migrate - -# Load fixtures (Optional) -docker compose exec phpfpm bin/console hautelook:fixtures:load --no-interaction -``` - -The fixtures have an admin user: with the password: apassword -The fixtures have an editor user: with the password: apassword -The fixtures have the image-text template, and two screen layouts: -full screen and "two boxes". - -## OIDC providers - -At the present two possible oidc providers are implemented: 'internal' and 'external'. -These work differently. - -The internal provider is expected to handle both authentication and authorization. -Any users logging in through the internal will be granted access based on the -tenants/roles provided. - -The external provider only handles authentication. A user logging in through the -external provider will not be granted access automatically, but will be challenged -to enter an activation (invite) code to verify access. - -### Internal - -The internal oidc provider gets that user's name, email and tenants from claims. - -The claim keys needed are set in the env variables: - -- `INTERNAL_OIDC_CLAIM_NAME` -- `INTERNAL_OIDC_CLAIM_EMAIL` -- `INTERNAL_OIDC_CLAIM_GROUPS` - -The value of the claim with the name that is defined in the env variable `INTERNAL_OIDC_CLAIM_GROUPS` is mapped to -the user's access to tenants in `App\Security\AzureOidcAuthenticator`. The claim field should consist of an array of -names that should follow the following structure ``. -`` can be `Admin` or `Redaktoer` (editor). -E.g. `Example1Admin` will map to the tenant with name `Example1` with `ROLE_ADMIN`. -If the tenant does not exist it will be created when the user logs in. - -### External - -The external oidc provider takes only the claim defined in the env variable -OIDC_EXTERNAL_CLAIM_ID, hashes it and uses this hash as providerId for the user. -When a user logs in with this provider, it is initially not in any tenant. -To be added to a tenant the user has to use an activation code a -ROLE_EXTERNAL_USER_ADMIN has created. - -## JWT Auth - -To authenticate against the API locally you must generate a private/public key pair: - -```shell -docker compose exec phpfpm bin/console lexik:jwt:generate-keypair -``` - -Then create a local test user if needed: - -```shell -docker compose exec phpfpm bin/console app:user:add -``` - -You can now obtain a token by sending a `POST` request to the -`/v2/authentication/token` endpoint: - -```curl -curl --location --request 'POST' \ - 'http://displayapiservice.local.itkdev.dk/v2/authentication/token' \ - --header 'accept: application/json' \ - --header 'Content-Type: application/json' \ - --data '{ - "email": "editor@example.com", - "password": "apassword" -}' -``` - -Either on the command line or through the OpenApi docs at `/docs` - -You can use the token either by clicking "Authorize" in the docs and entering - -```curl -Bearer -``` - -as the api key value. Or by adding an auth header to your requests - -```curl -curl --location --request 'GET' \ - 'http://displayapiservice.local.itkdev.dk/v2/layouts?page=1&itemsPerPage=10' \ - --header 'accept: application/ld+json' \ - --header 'Authorization: Bearer ' -``` - -### Psalm static analysis - -[Psalm](https://psalm.dev/) is used for static analysis. To run -Psalm do - -```shell -docker compose exec phpfpm composer install -docker compose exec phpfpm vendor/bin/psalm -``` - -We use [a baseline file](https://psalm.dev/docs/running_psalm/dealing_with_code_issues/#using-a-baseline-file) for Psalm -([`psalm-baseline.xml`](psalm-baseline.xml)). Run - -```shell -docker compose exec phpfpm vendor/bin/psalm --update-baseline -``` - -to update the baseline file. - -Psalm [error level](https://psalm.dev/docs/running_psalm/error_levels/) is set -to level 2. - -### Composer normalizer - -[Composer normalize](https://github.com/ergebnis/composer-normalize) is used for -formatting `composer.json` - -```shell -docker compose exec phpfpm composer normalize -``` - -### Tests - -Initialize test database: - -``` shell -docker compose exec phpfpm composer test-setup -``` - -Run tests: - -```shell -docker compose exec phpfpm composer test -``` - -A limited number of tests can be run by passing command line parameters to the command. - -By file - -```shell -docker compose exec phpfpm composer test tests/Api/UserTest.php -``` - -or by filtering to one method in the file - -```shell -docker compose exec phpfpm composer test tests/Api/UserTest.php --filter testExternalUserFlow -``` - -### Check Coding Standard - -The following command let you test that the code follows -the coding standard for the project. - -- PHP files [PHP Coding Standards Fixer](https://cs.symfony.com/) - -```shell -docker compose exec phpfpm composer coding-standards-check -``` - -- Markdown files (markdownlint standard rules) - -```shell -docker run --rm -v .:/app --workdir=/app node:20 npm install -docker run --rm -v .:/app --workdir=/app node:20 npm run coding-standards-check -``` - -#### YAML - -```sh -docker run --volume ${PWD}:/code --rm pipelinecomponents/yamllint yamllint config/api_platform -``` - -### Apply Coding Standards - -To attempt to automatically fix coding style issues - -- PHP files [PHP Coding Standards Fixer](https://cs.symfony.com/) - -```sh -docker compose exec phpfpm composer coding-standards-apply -``` - -- Markdown files (markdownlint standard rules) - -```shell -docker run --rm -v .:/app --workdir=/app node:18 npm install -docker run --rm -v .:/app --workdir=/app node:18 npm run coding-standards-apply -``` +## Documentation ## Tests -Run automated tests: - -```shell -docker compose exec phpfpm composer test setup -docker compose exec phpfpm composer test -``` +### API tests -Disable or hide deprecation warnings using the [`SYMFONY_DEPRECATIONS_HELPER` environment -variable](https://symfony.com/doc/current/components/phpunit_bridge.html#configuration), e.g. +TODO -```shell -docker compose exec --env SYMFONY_DEPRECATIONS_HELPER=disabled phpfpm composer tests -``` +### Admin / Client tests -## CI - -Github Actions are used to run the test suite and code style checks on all PRs. - -If you wish to test against the jobs locally you can install [act](https://github.com/nektos/act). -Then do: +To run tests, use the script: ```shell -act -P ubuntu-latest=shivammathur/node:latest pull_request +./scripts/test.sh ``` +This script will stop the node container, build the javascript/css assets, and run tests with playwright, +and starts the node container again. + ## Versioning We use [SemVer](http://semver.org/) for versioning. diff --git a/assets/admin/app.jsx b/assets/admin/app.jsx new file mode 100644 index 000000000..315bf4a45 --- /dev/null +++ b/assets/admin/app.jsx @@ -0,0 +1,471 @@ +import { React, useEffect, useState, Suspense } from "react"; +import { I18nextProvider } from "react-i18next"; +import { Routes, Route, Navigate } from "react-router-dom"; +import i18next from "i18next"; +import { ToastContainer } from "react-toastify"; +import Container from "react-bootstrap/Container"; +import Row from "react-bootstrap/Row"; +import Col from "react-bootstrap/Col"; +import localStorageKeys from "./components/util/local-storage-keys"; +import RestrictedRoute from "./restricted-route"; +import Topbar from "./components/navigation/topbar/top-bar"; +import SideBar from "./components/navigation/sidebar/sidebar"; +import ScreenList from "./components/screen/screen-list"; +import SlidesList from "./components/slide/slides-list"; +import GroupsList from "./components/groups/groups-list"; +import GroupCreate from "./components/groups/group-create"; +import GroupEdit from "./components/groups/group-edit"; +import PlaylistCampaignList from "./components/playlist/playlist-campaign-list"; +import PlaylistCampaignEdit from "./components/playlist/playlist-campaign-edit"; +import PlaylistCampaignCreate from "./components/playlist/playlist-campaign-create"; +import MediaList from "./components/media/media-list"; +import commonDa from "./translations/da/common.json"; +import ScreenCreate from "./components/screen/screen-create"; +import ScreenEdit from "./components/screen/screen-edit"; +import SlideEdit from "./components/slide/slide-edit"; +import SlideCreate from "./components/slide/slide-create"; +import MediaCreate from "./components/media/media-create"; +import ThemesList from "./components/themes/themes-list"; +import ThemeCreate from "./components/themes/theme-create"; +import ThemeEdit from "./components/themes/theme-edit"; +import UserContext from "./context/user-context"; +import ListContext from "./context/list-context"; +import SharedPlaylists from "./components/playlist/shared-playlists"; +import Logout from "./components/user/logout"; +import AuthHandler from "./auth-handler"; +import LoadingComponent from "./components/util/loading-component/loading-component"; +import ModalProvider from "./context/modal-context/modal-provider"; +import UsersList from "./components/users/users-list"; +import ActivationCodeList from "./components/activation-code/activation-code-list"; +import ActivationCodeCreate from "./components/activation-code/activation-code-create"; +import ActivationCodeActivate from "./components/activation-code/activation-code-activate"; +import ConfigLoader from "../shared/config-loader.js"; +import "react-toastify/dist/ReactToastify.css"; +import "./app.scss"; +import FeedSourcesList from "./components/feed-sources/feed-sources-list"; +import FeedSourceCreate from "./components/feed-sources/feed-source-create"; +import FeedSourceEdit from "./components/feed-sources/feed-source-edit"; + +/** + * App component. + * + * @returns {object} The component. + */ +function App() { + const [authenticated, setAuthenticated] = useState(); + const [config, setConfig] = useState(); + const [selectedTenant, setSelectedTenant] = useState(); + const [accessConfig, setAccessConfig] = useState(); + const [tenants, setTenants] = useState(); + const [userName, setUserName] = useState(""); + const [userType, setUserType] = useState(""); + const [email, setEmail] = useState(""); + const [searchText, setSearchText] = useState(""); + const [page, setPage] = useState(1); + const [createdBy, setCreatedBy] = useState("all"); + const [isPublished, setIsPublished] = useState("all"); + const [exists, setExists] = useState(null); + const [screenUserLatestRequest, setScreenUserLatestRequest] = useState(null); + + const userStore = { + authenticated: { get: authenticated, set: setAuthenticated }, + accessConfig: { get: accessConfig, set: setAccessConfig }, + config, + tenants: { get: tenants, set: setTenants }, + selectedTenant: { get: selectedTenant, set: setSelectedTenant }, + userName: { get: userName, set: setUserName }, + userType: { get: userType, set: setUserType }, + email: { get: email, set: setEmail }, + }; + const listConfig = { + searchText: { get: searchText, set: setSearchText }, + page: { get: page, set: setPage }, + createdBy: { get: createdBy, set: setCreatedBy }, + isPublished: { get: isPublished, set: setIsPublished }, + exists: { get: exists, set: setExists }, + screenUserLatestRequest: { + get: screenUserLatestRequest, + set: setScreenUserLatestRequest, + }, + }; + + useEffect(() => { + setConfig(ConfigLoader.getConfig()); + }, []); + + const handleReauthenticate = () => { + localStorage.removeItem(localStorageKeys.API_TOKEN); + localStorage.removeItem(localStorageKeys.API_REFRESH_TOKEN); + localStorage.removeItem(localStorageKeys.SELECTED_TENANT); + localStorage.removeItem(localStorageKeys.TENANTS); + localStorage.removeItem(localStorageKeys.USER_NAME); + localStorage.removeItem(localStorageKeys.EMAIL); + localStorage.removeItem(localStorageKeys.USER_TYPE); + + setSelectedTenant(null); + setTenants(null); + setUserName(""); + setEmail(""); + setUserType(""); + setAuthenticated(false); + }; + + // Check that authentication token exists. + useEffect(() => { + const token = localStorage.getItem(localStorageKeys.API_TOKEN); + + if (token !== null) { + setAuthenticated(true); + + // If there is a selected tenant, fetch from local storage and use + if (localStorage.getItem(localStorageKeys.SELECTED_TENANT)) { + setSelectedTenant( + JSON.parse(localStorage.getItem(localStorageKeys.SELECTED_TENANT)) + ); + } + + // Fetch the users tenants from local storage and use + if (localStorage.getItem(localStorageKeys.TENANTS)) { + setTenants(JSON.parse(localStorage.getItem(localStorageKeys.TENANTS))); + } + + // Get the user name for displaying in top bar. + setUserName(localStorage.getItem(localStorageKeys.USER_NAME)); + + // Get the user email for displaying in top bar. + setEmail(localStorage.getItem(localStorageKeys.EMAIL)); + + // Set the user type from local storage. + setUserType(localStorage.getItem(localStorageKeys.USER_TYPE)); + } else { + setAuthenticated(false); + } + + document.addEventListener("reauthenticate", handleReauthenticate); + + return () => { + document.removeEventListener("reauthenticate", handleReauthenticate); + }; + }, []); + + useEffect(() => { + fetch("/admin/access-config.json") + .then((response) => response.json()) + .then((jsonData) => { + setAccessConfig(jsonData); + }) + .catch(() => { + setAccessConfig({ + campaign: { + roles: ["ROLE_ADMIN"], + }, + screen: { + roles: ["ROLE_ADMIN"], + }, + settings: { + roles: ["ROLE_ADMIN"], + }, + groups: { + roles: ["ROLE_ADMIN"], + }, + shared: { + roles: ["ROLE_ADMIN"], + }, + users: { + roles: ["ROLE_ADMIN", "ROLE_EXTERNAL_USER_ADMIN"], + }, + }); + }); + }, []); + + useEffect(() => { + i18next.init({ + interpolation: { escapeValue: false }, // React already does escaping + lng: "da", // language to use + keySeparator: ".", + resources: { + da: { + common: commonDa, + }, + }, + }); + }, []); + + return ( + <> + + + + + } + > + + + + + + + + + {accessConfig && ( +
+ + + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + } + /> + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + } + /> + + + + } + /> + + + + + + } + /> + + + + } + /> + + + + } + /> + + + } /> + } + /> + } + /> + + + } /> + } + /> + + + } + /> + } + /> + } + /> + + + } /> + + + + + + } + /> + + + + } + /> + + + + } + /> + + + + + + } + /> + + + + } + /> + + + + } + /> + + } /> + } + /> + +
+ )} + +
+
+
+
+
+
+
+
+ + ); +} + +export default App; diff --git a/assets/admin/app.scss b/assets/admin/app.scss new file mode 100644 index 000000000..99e2130b8 --- /dev/null +++ b/assets/admin/app.scss @@ -0,0 +1,145 @@ +@use "bootstrap/scss/bootstrap" as *; + +html { + height: -webkit-fill-available; +} + +body, +.row-full-height { + min-height: 100vh; +} + +body { + background: #f8f9fa; +} + +.root { + display: flex; + flex-wrap: nowrap; + height: 100vh; + height: -webkit-fill-available; + max-height: 100vh; + // overflow-x: auto; + // overflow-y: hidden; // TODO: Why was this added? +} + +// To make multicomponent look like bootstrap for now +// @TODO: change when changing the frontend styling +.invalid { + .dropdown-container { + border-color: $red; + } + + .invalid-feedback-multi { + margin-top: 0.25rem; + font-size: 0.875em; + color: $red; + } + + .invalid-feedback-image-uploader { + margin-top: 0.25rem; + font-size: 0.875em; + color: $red; + } +} + +.toast-wrapper { + position: fixed; + right: 10px; + bottom: 3%; + background-color: white; +} + +.spinner-overlay { + background-color: white; + color: black; + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + display: flex; + justify-content: center; + padding: 5em; + z-index: 1021; + + .loading-spinner { + margin-right: 0.6em; + } +} + +.preview-overlay { + background: white; + z-index: 1021; + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + overflow: hidden; + + button { + position: fixed; + z-index: 20; + } +} + +.table { + td { + vertical-align: middle; + } + + td:last-child { + button { + float: right; + } + .btn-secondary { + float: left; + } + } +} + +.margin-right-button { + @include media-breakpoint-up(lg) { + margin-right: 1rem; + } +} + +.preview-actions { + display: flex; + justify-content: space-between; + align-items: center; +} + +.preview-button-container { + display: none; + position: fixed; + border: 3px solid #f8f9fa; + background: white; + right: 0; + top: 50%; + flex-direction: column; + + @media (max-width: 800px) { + display: flex; + z-index: 2; + } + + .preview-button { + justify-content: center; + align-content: center; + align-items: center; + } +} + +.preview-full-screen-text { + @media (max-width: 1460px) { + display: none; + } +} + +.preview-close-button { + top: 0; + right: 0; + margin: 2em; +} diff --git a/assets/admin/auth-handler.jsx b/assets/admin/auth-handler.jsx new file mode 100644 index 000000000..510942713 --- /dev/null +++ b/assets/admin/auth-handler.jsx @@ -0,0 +1,27 @@ +import { React, useContext } from "react"; +import PropTypes from "prop-types"; +import Login from "./components/user/login"; +import UserContext from "./context/user-context"; + +/** + * The auth handler wrapper. + * + * @param {object} props - The props. + * @param {Array} props.children The children being passed from parent + * @returns {object} A spinner, a login page or the children. + */ +function AuthHandler({ children }) { + const context = useContext(UserContext); + + if (!context.authenticated.get || !context.selectedTenant.get) { + return ; + } + + return children; +} + +AuthHandler.propTypes = { + children: PropTypes.node.isRequired, +}; + +export default AuthHandler; diff --git a/assets/admin/components/activation-code/activation-code-activate.jsx b/assets/admin/components/activation-code/activation-code-activate.jsx new file mode 100644 index 000000000..b365b89dd --- /dev/null +++ b/assets/admin/components/activation-code/activation-code-activate.jsx @@ -0,0 +1,106 @@ +import { React, useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import Form from "react-bootstrap/Form"; +import { Button } from "react-bootstrap"; +import { usePostV2UserActivationCodesActivateMutation } from "../../redux/api/api.generated.ts"; +import { + displaySuccess, + displayError, +} from "../util/list/toast-component/display-toast"; +import LoadingComponent from "../util/loading-component/loading-component"; +import ContentBody from "../util/content-body/content-body"; +import FormInput from "../util/forms/form-input"; +import ContentFooter from "../util/content-footer/content-footer"; + +/** + * The activation code activate page. + * + * @returns {object} The activation code activate page. + */ +function ActivationCodeActivate() { + const { t } = useTranslation("common", { + keyPrefix: "activation-code-create", + }); + const navigate = useNavigate(); + const [formStateObject, setFormStateObject] = useState({ + activationCode: "", + }); + + const [ + PostV2UserActivationCodeActivate, + { error: saveError, isLoading: isSaving, isSuccess: isSaveSuccess }, + ] = usePostV2UserActivationCodesActivateMutation(); + + /** Handle submitting is done. */ + useEffect(() => { + if (isSaveSuccess) { + displaySuccess(t("success-messages.saved-activation-code")); + navigate("/activation/list"); + } + }, [isSaveSuccess]); + + /** If the user is saved with error, display the error message */ + useEffect(() => { + if (saveError) { + displayError(t("error-messages.save-activation-code-error"), saveError); + } + }, [saveError]); + + /** + * Set state on change in input field + * + * @param {object} props The props. + * @param {object} props.target Event target. + */ + const handleInput = ({ target }) => { + const localFormStateObject = { ...formStateObject }; + localFormStateObject[target.id] = target.value; + setFormStateObject(localFormStateObject); + }; + + /** Handles submit. */ + const handleSubmit = () => { + PostV2UserActivationCodeActivate({ + userActivationCodeActivationCode: JSON.stringify(formStateObject), + }); + }; + + return ( +
+ +
+

{t("header")}

+ + + + + + +
+
+ ); +} + +export default ActivationCodeActivate; diff --git a/assets/admin/components/activation-code/activation-code-columns.jsx b/assets/admin/components/activation-code/activation-code-columns.jsx new file mode 100644 index 000000000..9350f5db9 --- /dev/null +++ b/assets/admin/components/activation-code/activation-code-columns.jsx @@ -0,0 +1,56 @@ +import { React } from "react"; +import { useTranslation } from "react-i18next"; +import dayjs from "dayjs"; +import SelectColumnHoc from "../util/select-column-hoc"; +import ColumnHoc from "../util/column-hoc"; + +/** + * Columns for ActivationCode lists. + * + * @returns {object} The columns for the users lists. + */ +function getActivationCodeColumns() { + const { t } = useTranslation("common", { keyPrefix: "activation-code-list" }); + + return [ + { + path: "code", + label: t("columns.code"), + }, + { + path: "codeExpire", + label: t("columns.code-expire"), + dataFunction: (data) => { + const date = dayjs(data); + const expired = date < dayjs(); + + return ( + + {date.format("YYYY-MM-DD HH:mm")} + + ); + }, + }, + { + path: "username", + label: t("columns.display-name"), + }, + { + path: "createdAt", + label: t("columns.created-at"), + dataFunction: (data) => dayjs(data).format("YYYY-MM-DD"), + }, + { + path: "createdBy", + label: t("columns.created-by"), + }, + ]; +} + +const ActivationCodeColumns = ColumnHoc(getActivationCodeColumns, true, true); +const SelectActivationCodeColumns = SelectColumnHoc( + getActivationCodeColumns, + true +); + +export { SelectActivationCodeColumns, ActivationCodeColumns }; diff --git a/assets/admin/components/activation-code/activation-code-create.jsx b/assets/admin/components/activation-code/activation-code-create.jsx new file mode 100644 index 000000000..4762f7bc9 --- /dev/null +++ b/assets/admin/components/activation-code/activation-code-create.jsx @@ -0,0 +1,83 @@ +import { React, useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { usePostV2UserActivationCodesMutation } from "../../redux/api/api.generated.ts"; +import ActivationCodeForm from "./activation-code-form"; +import { + displaySuccess, + displayError, +} from "../util/list/toast-component/display-toast"; + +/** + * The user create component. + * + * @returns {object} The user create page. + */ +function ActivationCodeCreate() { + const { t } = useTranslation("common", { + keyPrefix: "activation-code-create", + }); + const navigate = useNavigate(); + const headerText = t("create-new-activation-code-header"); + const [formStateObject, setFormStateObject] = useState({ + displayName: "", + role: "", + }); + + const [ + PostV2UserActivationCode, + { error: saveError, isLoading: isSaving, isSuccess: isSaveSuccess }, + ] = usePostV2UserActivationCodesMutation(); + + /** Handle submitting is done. */ + useEffect(() => { + if (isSaveSuccess) { + displaySuccess(t("success-messages.saved-activation-code")); + navigate("/activation/list"); + } + }, [isSaveSuccess]); + + /** If the user is saved with error, display the error message */ + useEffect(() => { + if (saveError) { + displayError(t("error-messages.save-activation-code-error"), saveError); + } + }, [saveError]); + + /** + * Set state on change in input field + * + * @param {object} props The props. + * @param {object} props.target Event target. + */ + const handleInput = ({ target }) => { + const localFormStateObject = { ...formStateObject }; + localFormStateObject[target.id] = target.value; + setFormStateObject(localFormStateObject); + }; + + /** Handles submit. */ + const handleSubmit = () => { + const saveData = { + displayName: formStateObject.displayName, + roles: [formStateObject.role], + }; + + PostV2UserActivationCode({ + userActivationCodeUserActivationCodeInput: JSON.stringify(saveData), + }); + }; + + return ( + + ); +} + +export default ActivationCodeCreate; diff --git a/assets/admin/components/activation-code/activation-code-form.jsx b/assets/admin/components/activation-code/activation-code-form.jsx new file mode 100644 index 000000000..6d4b4c633 --- /dev/null +++ b/assets/admin/components/activation-code/activation-code-form.jsx @@ -0,0 +1,122 @@ +import { React } from "react"; +import { useNavigate } from "react-router-dom"; +import { Button, Col, Row } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; +import PropTypes from "prop-types"; +import Form from "react-bootstrap/Form"; +import LoadingComponent from "../util/loading-component/loading-component"; +import ContentBody from "../util/content-body/content-body"; +import FormInput from "../util/forms/form-input"; +import RadioButtons from "../util/forms/radio-buttons"; +import StickyFooter from "../util/sticky-footer"; + +/** + * The user form component. + * + * @param {object} props - The props. + * @param {object} props.activationCode The activationCode object to modify in the form. + * @param {Function} props.handleInput Handles form input. + * @param {Function} props.handleSubmit Handles form submit. + * @param {string} props.headerText Headline text. + * @param {boolean} props.isLoading Indicator of whether the form is loading + * @param {string} props.loadingMessage The loading message for the spinner + * @returns {object} The user form. + */ +function ActivationCodeForm({ + handleInput, + handleSubmit, + headerText, + isLoading = false, + loadingMessage = "", + activationCode, +}) { + const { t } = useTranslation("common", { keyPrefix: "activation-code-form" }); + const navigate = useNavigate(); + + const roles = [ + { + id: "ROLE_EXTERNAL_USER", + label: t("role-external-user"), + }, + { + id: "ROLE_EXTERNAL_USER_ADMIN", + label: t("role-external-user-admin"), + }, + ]; + + return ( + <> + +
+ +

{headerText}

+ + +
+ +
+
+ +
+ {t("role-external-user-helptext")} +
+
+ {t("role-external-user-admin-helptext")} +
+
+
+ +
+ + + + + +
+ + ); +} + +ActivationCodeForm.propTypes = { + activationCode: PropTypes.shape({ + displayName: PropTypes.string.isRequired, + role: PropTypes.string.isRequired, + }).isRequired, + handleInput: PropTypes.func.isRequired, + handleSubmit: PropTypes.func.isRequired, + headerText: PropTypes.string.isRequired, + isLoading: PropTypes.bool, + loadingMessage: PropTypes.string, +}; + +export default ActivationCodeForm; diff --git a/assets/admin/components/activation-code/activation-code-list.jsx b/assets/admin/components/activation-code/activation-code-list.jsx new file mode 100644 index 000000000..cbf72dd99 --- /dev/null +++ b/assets/admin/components/activation-code/activation-code-list.jsx @@ -0,0 +1,215 @@ +import { React, useEffect, useState, useContext } from "react"; +import { useTranslation } from "react-i18next"; +import { useDispatch } from "react-redux"; +import { Button } from "react-bootstrap"; +import List from "../util/list/list"; +import ListContext from "../../context/list-context"; +import UserContext from "../../context/user-context"; +import useModal from "../../context/modal-context/modal-context-hook"; +import { ActivationCodeColumns } from "./activation-code-columns"; +import ContentHeader from "../util/content-header/content-header"; +import ContentBody from "../util/content-body/content-body"; +import idFromUrl from "../util/helpers/id-from-url"; +import { + displaySuccess, + displayError, +} from "../util/list/toast-component/display-toast"; +import { + api, + useDeleteV2UserActivationCodesByIdMutation, + useGetV2UserActivationCodesQuery, +} from "../../redux/api/api.generated.ts"; + +/** + * The Activation Code list component. + * + * @returns {object} The users list. + */ +function ActivationCodeList() { + const { t } = useTranslation("common", { keyPrefix: "activation-code-list" }); + const { selected, setSelected } = useModal(); + const { + searchText: { get: searchText }, + page: { get: page }, + createdBy: { get: createdBy }, + } = useContext(ListContext); + const context = useContext(UserContext); + + // Local state + const [items, setItems] = useState([]); + const [isDeleting, setIsDeleting] = useState(false); + const [listData, setListData] = useState(); + const [loadingMessage, setLoadingMessage] = useState( + t("loading-messages.loading-activation-code") + ); + + // Remove from tenant call + const [ + DeleteV2UserActivationCode, + { isSuccess: isDeleteSuccess, error: isDeleteError }, + ] = useDeleteV2UserActivationCodesByIdMutation(); + + // Get method + const { + data, + error: activationCodeGetError, + isLoading, + refetch, + } = useGetV2UserActivationCodesQuery({ + page, + order: { createdAt: "desc" }, + title: searchText, + createdBy, + }); + + useEffect(() => { + if (data) { + setListData(data); + } + }, [data]); + + useEffect(() => { + refetch(); + }, [searchText, page, createdBy]); + + /** Deletes multiple codes. */ + useEffect(() => { + if (isDeleting && selected.length > 0) { + const codeToDelete = selected[0]; + setSelected(selected.slice(1)); + const codeToDeleteId = idFromUrl(codeToDelete.id); + DeleteV2UserActivationCode({ id: codeToDeleteId }); + } + }, [isDeleting, isDeleteSuccess]); + + // Sets success messages in local storage, because the page is reloaded + useEffect(() => { + if (isDeleteSuccess && selected.length === 0) { + displaySuccess(t("success-messages.activation-code-delete")); + refetch(); + setIsDeleting(false); + } + }, [isDeleteSuccess]); + + // If the tenant is changed, data should be refetched + useEffect(() => { + if (context.selectedTenant.get) { + refetch(); + } + }, [context.selectedTenant.get]); + + // Display error on unsuccessful deletion + useEffect(() => { + if (isDeleteError) { + setIsDeleting(false); + displayError( + t("error-messages.activation-code-delete-error"), + isDeleteError + ); + } + }, [isDeleteError]); + + /** Starts the deletion process. */ + const handleDelete = () => { + setIsDeleting(true); + setLoadingMessage(t("loading-messages.deleting-activation-code")); + }; + + // Error with retrieving list of users + useEffect(() => { + if (activationCodeGetError) { + displayError( + t("error-messages.activation-code-load-error"), + activationCodeGetError + ); + } + }, [activationCodeGetError]); + + const dispatch = useDispatch(); + + const refreshCallback = (id) => { + const item = items.filter((e) => e["@id"] === id); + + if (item.length !== 1) { + return; + } + + dispatch( + api.endpoints.postV2UserActivationCodesRefresh.initiate({ + userActivationCodeActivationCode: JSON.stringify({ + activationCode: item[0].code, + }), + }) + ) + .then((response) => { + if (response.data) { + refetch(); + } + }) + .catch((err) => { + displayError(t("error-refreshing-code"), err); + }); + }; + + // The columns for the table. + const columns = ActivationCodeColumns({ handleDelete }); + + columns.push({ + path: "@id", + dataFunction: (id) => { + return ( + + ); + }, + label: "", + }); + + useEffect(() => { + if (listData) { + // Set title from code, for use with delete modal. + const newItems = [...(listData["hydra:member"] ?? [])].map((el) => { + return { + ...el, + title: el.code, + }; + }); + + setItems(newItems); + } + }, [listData]); + + return ( +
+ + + <> + {listData && ( + + )} + + +
+ ); +} + +export default ActivationCodeList; diff --git a/assets/admin/components/feed-sources/feed-source-create.jsx b/assets/admin/components/feed-sources/feed-source-create.jsx new file mode 100644 index 000000000..53d795683 --- /dev/null +++ b/assets/admin/components/feed-sources/feed-source-create.jsx @@ -0,0 +1,21 @@ +import { React } from "react"; +import FeedSourceManager from "./feed-source-manager"; + +/** + * The themes create component. + * + * @returns {object} The themes create page. + */ +function FeedSourceCreate() { + // Initialize to empty feed source object. + const data = { + title: "", + description: "", + feedType: "", + secrets: {}, + }; + + return ; +} + +export default FeedSourceCreate; diff --git a/assets/admin/components/feed-sources/feed-source-edit.jsx b/assets/admin/components/feed-sources/feed-source-edit.jsx new file mode 100644 index 000000000..4766320b0 --- /dev/null +++ b/assets/admin/components/feed-sources/feed-source-edit.jsx @@ -0,0 +1,30 @@ +import { React } from "react"; +import { useParams } from "react-router-dom"; +import { useGetV2FeedSourcesByIdQuery } from "../../redux/api/api.generated.ts"; +import FeedSourceManager from "./feed-source-manager"; + +/** + * The feed source edit component. + * + * @returns {object} The feed sources edit page. + */ +function FeedSourceEdit() { + const { id } = useParams(); + const { + data, + error: loadingError, + isLoading, + } = useGetV2FeedSourcesByIdQuery({ id }); + + return ( + + ); +} + +export default FeedSourceEdit; diff --git a/assets/admin/components/feed-sources/feed-source-form.jsx b/assets/admin/components/feed-sources/feed-source-form.jsx new file mode 100644 index 000000000..31b7d2d8f --- /dev/null +++ b/assets/admin/components/feed-sources/feed-source-form.jsx @@ -0,0 +1,214 @@ +import { React } from "react"; +import { Alert, Button, Row, Col } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import PropTypes from "prop-types"; +import Form from "react-bootstrap/Form"; +import LoadingComponent from "../util/loading-component/loading-component"; +import FormInputArea from "../util/forms/form-input-area"; +import FormSelect from "../util/forms/select"; +import ContentBody from "../util/content-body/content-body"; +import FormInput from "../util/forms/form-input"; +import CalendarApiFeedType from "./templates/calendar-api-feed-type"; +import NotifiedFeedType from "./templates/notified-feed-type"; +import EventDatabaseApiFeedType from "./templates/event-database-feed-type"; +import ColiboFeedType from "./templates/colibo-feed-type"; +import StickyFooter from "../util/sticky-footer"; +import EventDatabaseApiV2FeedType from "./templates/event-database-v2-feed-type"; + +/** + * The feed-source form component. + * + * @param {object} props - The props. + * @param {object} props.feedSource The feed-source object to modify in the form. + * @param {Function} props.handleInput Handles form input. + * @param {Function} props.handleSubmit Handles form submit. + * @param {string} props.headerText Headline text. + * @param {boolean} [props.isLoading] Indicator of whether the form is loading. + * Default is `false` + * @param {string} [props.loadingMessage] The loading message for the spinner. + * Default is `""` + * @param {object} props.feedSourceTypeOptions The options for feed source types + * @param {string} props.mode The mode + * @param {Function} props.onFeedTypeChange Callback on feed type change. + * @param {Function} props.handleSecretInput Callback on secret input change. + * @param {Function} props.handleSaveNoClose Handles save but stays on page. + * @returns {object} The feed-source form. + */ +function FeedSourceForm({ + handleInput, + handleSubmit, + handleSaveNoClose, + headerText, + isLoading = false, + loadingMessage = "", + feedSource = null, + feedSourceTypeOptions = null, + onFeedTypeChange = () => {}, + handleSecretInput = () => {}, + mode = null, +}) { + const { t } = useTranslation("common", { keyPrefix: "feed-source-form" }); + const navigate = useNavigate(); + + const typeInOptions = + !feedSource?.feedType || + feedSourceTypeOptions.find((el) => el.value === feedSource.feedType); + + return ( + <> +
+ + +

{headerText}

+ + + + + {typeInOptions && ( + + )} + {!typeInOptions && ( + <> + {}} + /> + + {t("feed-type-not-supported")} + + + )} + + {feedSource?.feedType === "App\\Feed\\CalendarApiFeedType" && ( + + )} + {feedSource?.feedType === "App\\Feed\\ColiboFeedType" && ( + + )} + {feedSource?.feedType === + "App\\Feed\\EventDatabaseApiFeedType" && ( + + )} + {feedSource?.feedType === + "App\\Feed\\EventDatabaseApiV2FeedType" && ( + + )} + {feedSource?.feedType === "App\\Feed\\NotifiedFeedType" && ( + + )} + + +
+ + + + + + + + ); +} + +FeedSourceForm.propTypes = { + feedSource: PropTypes.shape({ + title: PropTypes.string, + description: PropTypes.string, + feedType: PropTypes.string, + supportedFeedOutputType: PropTypes.string, + }), + handleInput: PropTypes.func.isRequired, + handleSubmit: PropTypes.func.isRequired, + handleSaveNoClose: PropTypes.func.isRequired, + handleSecretInput: PropTypes.func.isRequired, + onFeedTypeChange: PropTypes.func.isRequired, + headerText: PropTypes.string.isRequired, + isLoading: PropTypes.bool, + loadingMessage: PropTypes.string, + feedSourceTypeOptions: PropTypes.arrayOf( + PropTypes.shape({ + value: PropTypes.string.isRequired, + title: PropTypes.string, + key: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + template: PropTypes.element, + }) + ).isRequired, + mode: PropTypes.string, +}; + +export default FeedSourceForm; diff --git a/assets/admin/components/feed-sources/feed-source-manager.jsx b/assets/admin/components/feed-sources/feed-source-manager.jsx new file mode 100644 index 000000000..0499cd172 --- /dev/null +++ b/assets/admin/components/feed-sources/feed-source-manager.jsx @@ -0,0 +1,262 @@ +import { React, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import PropTypes from "prop-types"; +import { useNavigate } from "react-router-dom"; +import FeedSourceForm from "./feed-source-form"; +import { + usePostV2FeedSourcesMutation, + usePutV2FeedSourcesByIdMutation, +} from "../../redux/api/api.generated.ts"; +import { + displayError, + displaySuccess, +} from "../util/list/toast-component/display-toast"; +import idFromUrl from "../util/helpers/id-from-url"; + +/** + * The theme manager component. + * + * @param {object} props The props. + * @param {object} props.initialState Initial theme state. + * @param {string} props.saveMethod POST or PUT. + * @param {string | null} props.id Theme id. + * @param {boolean} props.isLoading Is the theme state loading + * @param {object} props.loadingError Loading error. + * @returns {object} The theme form. + */ +function FeedSourceManager({ + saveMethod, + id = null, + isLoading = false, + loadingError = null, + initialState = null, +}) { + // Hooks + const { t } = useTranslation("common", { + keyPrefix: "feed-source-manager", + }); + const navigate = useNavigate(); + + // State + const [headerText] = useState( + saveMethod === "PUT" ? t("edit-feed-source") : t("create-new-feed-source") + ); + + const [loadingMessage, setLoadingMessage] = useState( + t("loading-messages.loading-feed-source") + ); + + const [submitting, setSubmitting] = useState(false); + const [formStateObject, setFormStateObject] = useState({}); + const [saveWithoutClose, setSaveWithoutClose] = useState(false); + + const [ + postV2FeedSources, + { error: saveErrorPost, isSuccess: isSaveSuccessPost, data }, + ] = usePostV2FeedSourcesMutation(); + + const [ + PutV2FeedSourcesById, + { error: saveErrorPut, isSuccess: isSaveSuccessPut }, + ] = usePutV2FeedSourcesByIdMutation(); + + const feedSourceTypeOptions = [ + { + value: "App\\Feed\\CalendarApiFeedType", + title: t("dynamic-fields.calendar-api-feed-type.title"), + key: "0", + secretsDefault: { + locations: [], + }, + }, + { + value: "App\\Feed\\ColiboFeedType", + title: t("colibo-feed-type.title"), + key: "1", + secretsDefault: { + api_base_uri: "", + client_id: "", + client_secret: "", + recipients: [], + }, + }, + { + value: "App\\Feed\\EventDatabaseApiFeedType", + title: t("dynamic-fields.event-database-api-feed-type.title"), + key: "2", + secretsDefault: { + host: "", + }, + }, + { + value: "App\\Feed\\EventDatabaseApiV2FeedType", + title: t("event-database-api-v2-feed-type.title"), + key: "7", + secretsDefault: { + host: "", + apikey: "", + }, + }, + { + value: "App\\Feed\\NotifiedFeedType", + title: t("dynamic-fields.notified-feed-type.title"), + key: "3", + secretsDefault: { + token: "", + }, + }, + { + value: "App\\Feed\\RssFeedType", + title: t("dynamic-fields.rss-feed-type.title"), + key: "4", + secretsDefault: {}, + }, + ]; + + /** + * Set state on change in input field + * + * @param {object} props - The props. + * @param {object} props.target - Event target. + */ + const handleInput = ({ target }) => { + const localFormStateObject = { ...formStateObject }; + localFormStateObject[target.id] = target.value; + setFormStateObject(localFormStateObject); + }; + + /** Set loaded data into form state. */ + useEffect(() => { + const newState = { ...initialState }; + + if (newState.secrets instanceof Array) { + newState.secrets = {}; + } + + setFormStateObject(newState); + }, [initialState]); + + const handleSecretInput = ({ target }) => { + const secrets = { ...formStateObject.secrets }; + secrets[target.id] = target.value; + setFormStateObject({ ...formStateObject, secrets }); + }; + + const onFeedTypeChange = ({ target }) => { + const { value } = target; + const option = feedSourceTypeOptions.find((opt) => opt.value === value); + const newFormStateObject = { ...formStateObject }; + newFormStateObject.feedType = value; + newFormStateObject.secrets = { ...option.secretsDefault }; + setFormStateObject(newFormStateObject); + }; + + /** Save feed source. */ + function saveFeedSource() { + setLoadingMessage(t("loading-messages.saving-feed-source")); + + if (saveMethod === "POST") { + postV2FeedSources({ + feedSourceFeedSourceInput: JSON.stringify(formStateObject), + }); + } else if (saveMethod === "PUT") { + PutV2FeedSourcesById({ + feedSourceFeedSourceInput: JSON.stringify(formStateObject), + id, + }); + } + } + + /** If the feed source is not loaded, display the error message */ + useEffect(() => { + if (loadingError) { + displayError( + t("error-messages.load-feed-source-error", { id }), + loadingError + ); + } + }, [loadingError]); + + /** When the media is saved, the theme will be saved. */ + useEffect(() => { + if (isSaveSuccessPost || isSaveSuccessPut) { + setSubmitting(false); + displaySuccess(t("success-messages.saved-feed-source")); + + if (saveWithoutClose) { + setSaveWithoutClose(false); + + if (isSaveSuccessPost) { + navigate(`/feed-sources/edit/${idFromUrl(data["@id"])}`); + } + } else { + navigate(`/feed-sources/list`); + } + } + }, [isSaveSuccessPut, isSaveSuccessPost]); + + /** Handles submit. */ + const handleSubmit = () => { + setSubmitting(true); + saveFeedSource(); + }; + + const handleSaveNoClose = () => { + setSaveWithoutClose(true); + handleSubmit(); + }; + + /** If the theme is saved with error, display the error message */ + useEffect(() => { + if (saveErrorPut || saveErrorPost) { + const saveError = saveErrorPut || saveErrorPost; + setSubmitting(false); + displayError(t("error-messages.save-feed-source-error"), saveError); + } + }, [saveErrorPut, saveErrorPost]); + + return ( + <> + {formStateObject && ( + + )} + + ); +} + +FeedSourceManager.propTypes = { + initialState: PropTypes.shape({ + title: PropTypes.string, + description: PropTypes.string, + feedType: PropTypes.string, + feedSourceType: PropTypes.string, + host: PropTypes.string, + token: PropTypes.string, + baseUrl: PropTypes.string, + clientId: PropTypes.string, + clientSecret: PropTypes.string, + feedSources: PropTypes.string, + }), + saveMethod: PropTypes.string.isRequired, + id: PropTypes.string, + isLoading: PropTypes.bool, + loadingError: PropTypes.shape({ + data: PropTypes.shape({ + status: PropTypes.number, + }), + }), +}; + +export default FeedSourceManager; diff --git a/assets/admin/components/feed-sources/feed-sources-columns.jsx b/assets/admin/components/feed-sources/feed-sources-columns.jsx new file mode 100644 index 000000000..a47109943 --- /dev/null +++ b/assets/admin/components/feed-sources/feed-sources-columns.jsx @@ -0,0 +1,58 @@ +import { React, useContext } from "react"; +import { useTranslation } from "react-i18next"; +import ColumnHoc from "../util/column-hoc"; +import ListButton from "../util/list/list-button"; +import SelectColumnHoc from "../util/select-column-hoc"; +import UserContext from "../../context/user-context"; + +/** + * Retrieves the columns for feed sources data based on API call response. + * + * @param {object} props - The props. + * @param {Function} props.apiCall - The API call function to retrieve feed sources data. + * @param {string} props.infoModalRedirect - The redirect URL for information modal. + * @param {string} props.infoModalTitle - The title for information modal. + * @returns {object} Columns - An array of objects representing the columns for + * feed sources data. + */ +function getFeedSourcesColumns({ apiCall, infoModalRedirect, infoModalTitle }) { + const context = useContext(UserContext); + const { t } = useTranslation("common", { keyPrefix: "feed-sources-list" }); + + const columns = [ + { + key: "publishing-from", + content: ({ feedType }) => <>{feedType}, + label: t("columns.feed-type"), + }, + { + key: "slides", + label: t("number-of-slides"), + render: ({ tenants }) => { + return ( + tenants?.length === 0 || + !tenants.find( + (tenant) => + tenant.tenantKey === context.selectedTenant.get.tenantKey + ) + ); + }, + // eslint-disable-next-line react/prop-types + content: ({ id }) => ( + + ), + }, + ]; + + return columns; +} + +const FeedSourceColumns = ColumnHoc(getFeedSourcesColumns); +const SelectFeedSourceColumns = SelectColumnHoc(getFeedSourcesColumns); + +export { SelectFeedSourceColumns, FeedSourceColumns }; diff --git a/assets/admin/components/feed-sources/feed-sources-list.jsx b/assets/admin/components/feed-sources/feed-sources-list.jsx new file mode 100644 index 000000000..514c4d595 --- /dev/null +++ b/assets/admin/components/feed-sources/feed-sources-list.jsx @@ -0,0 +1,157 @@ +import { React, useState, useEffect, useContext } from "react"; +import { useTranslation } from "react-i18next"; +import ContentHeader from "../util/content-header/content-header"; +import { + useGetV2FeedSourcesQuery, + useDeleteV2FeedSourcesByIdMutation, + useGetV2FeedSourcesByIdSlidesQuery, +} from "../../redux/api/api.generated.ts"; +import ListContext from "../../context/list-context"; +import ContentBody from "../util/content-body/content-body"; +import List from "../util/list/list"; +import { FeedSourceColumns } from "./feed-sources-columns"; +import { + displayError, + displaySuccess, +} from "../util/list/toast-component/display-toast"; +import idFromUrl from "../util/helpers/id-from-url"; +import UserContext from "../../context/user-context"; +import useModal from "../../context/modal-context/modal-context-hook"; + +/** + * The feed sources list component. + * + * @returns {object} The Feed sources list + */ +function FeedSourcesList() { + const { t } = useTranslation("common", { keyPrefix: "feed-sources-list" }); + const context = useContext(UserContext); + const { selected, setSelected } = useModal(); + + const [listData, setListData] = useState(); + const [isDeleting, setIsDeleting] = useState(false); + const [loadingMessage, setLoadingMessage] = useState( + t("loading-messages.loading-feed-sources") + ); + + // Delete call + const [ + DeleteV2FeedSources, + { isSuccess: isDeleteSuccess, error: isDeleteError }, + ] = useDeleteV2FeedSourcesByIdMutation(); // Insert feed source delete api; + + const { + searchText: { get: searchText }, + page: { get: page }, + createdBy: { get: createdBy }, + } = useContext(ListContext); + + const { + data, + error: feedSourcesGetError, + isLoading, + refetch, + } = useGetV2FeedSourcesQuery({ + page, + order: { createdAt: "desc" }, + title: searchText, + createdBy, + }); + + /** Deletes multiple feed sources. */ + useEffect(() => { + if (isDeleting && selected.length > 0) { + if (isDeleteSuccess) { + displaySuccess(t("success-messages.feed-source-delete")); + } + const feedSourceToDelete = selected[0]; + setSelected(selected.slice(1)); + const feedSourceToDeleteId = idFromUrl(feedSourceToDelete.id); + DeleteV2FeedSources({ id: feedSourceToDeleteId }); + } + }, [isDeleting, isDeleteSuccess]); + + // Display success messages + useEffect(() => { + if (isDeleteSuccess && selected.length === 0) { + displaySuccess(t("success-messages.feed-source-delete")); + refetch(); + setIsDeleting(false); + } + }, [isDeleteSuccess]); + + // If the tenant is changed, data should be refetched + useEffect(() => { + if (context.selectedTenant.get) { + refetch(); + } + }, [context.selectedTenant.get]); + + useEffect(() => { + refetch(); + }, [searchText, page, createdBy]); + + // Display error on unsuccessful deletion + useEffect(() => { + if (isDeleteError) { + setIsDeleting(false); + displayError(t("error-messages.feed-source-delete-error"), isDeleteError); + } + }, [isDeleteError]); + + const handleDelete = () => { + setIsDeleting(true); + setLoadingMessage(t("loading-messages.deleting-feed-source")); + }; + + // The columns for the table. + const columns = FeedSourceColumns({ + handleDelete, + apiCall: useGetV2FeedSourcesByIdSlidesQuery, + infoModalRedirect: "/slide/edit", + infoModalTitle: t(`info-modal.slides`), + }); + + useEffect(() => { + if (data) { + setListData(data); + } + }, [data]); + + // Error with retrieving list of feed sources + useEffect(() => { + if (feedSourcesGetError) { + displayError( + t("error-messages.feed-sources-load-error"), + feedSourcesGetError + ); + } + }, [feedSourcesGetError]); + + return ( +
+ + {data && data["hydra:member"] && ( + + <> + {listData && ( + + )} + + + )} +
+ ); +} + +export default FeedSourcesList; diff --git a/assets/admin/components/feed-sources/templates/calendar-api-feed-type.jsx b/assets/admin/components/feed-sources/templates/calendar-api-feed-type.jsx new file mode 100644 index 000000000..dd595bec6 --- /dev/null +++ b/assets/admin/components/feed-sources/templates/calendar-api-feed-type.jsx @@ -0,0 +1,54 @@ +import { React, useEffect, useState } from "react"; +import PropTypes from "prop-types"; +import { useTranslation } from "react-i18next"; +import { Alert } from "react-bootstrap"; +import MultiselectFromEndpoint from "../../slide/content/multiselect-from-endpoint"; + +const CalendarApiFeedType = ({ + feedSourceId, + handleInput, + formStateObject, +}) => { + const { t } = useTranslation("common", { + keyPrefix: "feed-source-manager.dynamic-fields.calendar-api-feed-type", + }); + + const [optionsEndpoint, setOptionsEndpoint] = useState(null); + + useEffect(() => { + if (feedSourceId && feedSourceId !== "") { + const endpoint = "/" + feedSourceId + "/config/locations"; + setOptionsEndpoint(endpoint); + } + }, [feedSourceId]); + + return ( + <> + {!feedSourceId && ( + + {t("save-before-locations-can-be-set")} + + )} + {optionsEndpoint && ( + + )} + + ); +}; + +CalendarApiFeedType.propTypes = { + handleInput: PropTypes.func, + formStateObject: PropTypes.shape({ + locations: PropTypes.arrayOf(PropTypes.string), + }), + feedSourceId: PropTypes.string, +}; + +export default CalendarApiFeedType; diff --git a/assets/admin/components/feed-sources/templates/colibo-feed-type.jsx b/assets/admin/components/feed-sources/templates/colibo-feed-type.jsx new file mode 100644 index 000000000..7ccd28913 --- /dev/null +++ b/assets/admin/components/feed-sources/templates/colibo-feed-type.jsx @@ -0,0 +1,101 @@ +import { React, useEffect, useState } from "react"; +import PropTypes from "prop-types"; +import { useTranslation } from "react-i18next"; +import { Alert } from "react-bootstrap"; +import MultiselectFromEndpoint from "../../slide/content/multiselect-from-endpoint"; +import FormInput from "../../util/forms/form-input"; + +const ColiboFeedType = ({ + feedSourceId, + handleInput, + formStateObject, + mode, +}) => { + const { t } = useTranslation("common", { + keyPrefix: "colibo-feed-type", + }); + + const [optionsEndpoint, setOptionsEndpoint] = useState(null); + + useEffect(() => { + if (feedSourceId && feedSourceId !== "") { + const endpoint = '/' + feedSourceId + "/config/recipients"; + setOptionsEndpoint(endpoint); + } + }, [feedSourceId]); + + return ( + <> + {!feedSourceId && ( + + {t("save-before-recipients-can-be-set")} + + )} + + + + + + + + + {t("values-info")} + + + {optionsEndpoint && ( + + )} + + ); +}; + +ColiboFeedType.propTypes = { + handleInput: PropTypes.func, + formStateObject: PropTypes.shape({ + api_base_uri: PropTypes.string, + client_id: PropTypes.string, + client_secret: PropTypes.string, + allowed_recipients: PropTypes.arrayOf(PropTypes.string), + }), + feedSourceId: PropTypes.string, + mode: PropTypes.string, +}; + +export default ColiboFeedType; diff --git a/assets/admin/components/feed-sources/templates/event-database-feed-type.jsx b/assets/admin/components/feed-sources/templates/event-database-feed-type.jsx new file mode 100644 index 000000000..9fd32b09b --- /dev/null +++ b/assets/admin/components/feed-sources/templates/event-database-feed-type.jsx @@ -0,0 +1,35 @@ +import { React } from "react"; +import PropTypes from "prop-types"; +import { useTranslation } from "react-i18next"; +import FormInput from "../../util/forms/form-input"; + +const EventDatabaseApiTemplate = ({ handleInput, formStateObject, mode }) => { + const { t } = useTranslation("common", { + keyPrefix: + "feed-source-manager.dynamic-fields.event-database-api-feed-type", + }); + return ( + <> + + + ); +}; + +EventDatabaseApiTemplate.propTypes = { + handleInput: PropTypes.func, + formStateObject: PropTypes.shape({ + host: PropTypes.string, + }), + mode: PropTypes.string, +}; + +export default EventDatabaseApiTemplate; diff --git a/assets/admin/components/feed-sources/templates/event-database-v2-feed-type.jsx b/assets/admin/components/feed-sources/templates/event-database-v2-feed-type.jsx new file mode 100644 index 000000000..4f39f39d6 --- /dev/null +++ b/assets/admin/components/feed-sources/templates/event-database-v2-feed-type.jsx @@ -0,0 +1,43 @@ +import { React } from "react"; +import PropTypes from "prop-types"; +import { useTranslation } from "react-i18next"; +import FormInput from "../../util/forms/form-input"; + +const EventDatabaseApiV2FeedType = ({ handleInput, formStateObject, mode }) => { + const { t } = useTranslation("common", { + keyPrefix: "event-database-api-v2-feed-type", + }); + return ( + <> + + + + ); +}; + +EventDatabaseApiV2FeedType.propTypes = { + handleInput: PropTypes.func, + formStateObject: PropTypes.shape({ + host: PropTypes.string.isRequired, + apikey: PropTypes.string, + }), + mode: PropTypes.string, +}; + +export default EventDatabaseApiV2FeedType; diff --git a/assets/admin/components/feed-sources/templates/notified-feed-type.jsx b/assets/admin/components/feed-sources/templates/notified-feed-type.jsx new file mode 100644 index 000000000..2f48a6b6f --- /dev/null +++ b/assets/admin/components/feed-sources/templates/notified-feed-type.jsx @@ -0,0 +1,35 @@ +import { React } from "react"; +import PropTypes from "prop-types"; +import { useTranslation } from "react-i18next"; +import FormInput from "../../util/forms/form-input"; + +const NotifiedFeedType = ({ handleInput, formStateObject, mode }) => { + const { t } = useTranslation("common", { + keyPrefix: "feed-source-manager.dynamic-fields.notified-feed-type", + }); + + return ( + <> + + + ); +}; + +NotifiedFeedType.propTypes = { + handleInput: PropTypes.func, + formStateObject: PropTypes.shape({ + token: PropTypes.string, + }), + mode: PropTypes.string, +}; + +export default NotifiedFeedType; diff --git a/assets/admin/components/groups/group-create.jsx b/assets/admin/components/groups/group-create.jsx new file mode 100644 index 000000000..c3ef0372b --- /dev/null +++ b/assets/admin/components/groups/group-create.jsx @@ -0,0 +1,87 @@ +import { React, useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { usePostV2ScreenGroupsMutation } from "../../redux/api/api.generated.ts"; +import GroupForm from "./group-form"; +import { + displaySuccess, + displayError, +} from "../util/list/toast-component/display-toast"; + +/** + * The group edit component. + * + * @returns {object} The group edit page. + */ +function GroupCreate() { + const { t } = useTranslation("common", { keyPrefix: "group-create" }); + const navigate = useNavigate(); + const headerText = t("create-new-group-header"); + const [formStateObject, setFormStateObject] = useState({ + title: "", + description: "", + createdBy: "", + modifiedBy: "", + }); + + const [ + PostV2ScreenGroups, + { error: saveError, isLoading: isSaving, isSuccess: isSaveSuccess }, + ] = usePostV2ScreenGroupsMutation(); + + /** Handle submitting is done. */ + useEffect(() => { + if (isSaveSuccess) { + displaySuccess(t("success-messages.saved-group")); + navigate("/group/list"); + } + }, [isSaveSuccess]); + + /** If the group is saved with error, display the error message */ + useEffect(() => { + if (saveError) { + displayError(t("error-messages.save-group-error"), saveError); + } + }, [saveError]); + + /** + * Set state on change in input field + * + * @param {object} props The props. + * @param {object} props.target Event target. + */ + const handleInput = ({ target }) => { + const localFormStateObject = { ...formStateObject }; + localFormStateObject[target.id] = target.value; + setFormStateObject(localFormStateObject); + }; + + /** Handles submit. */ + const handleSubmit = () => { + const saveData = { + title: formStateObject.title, + description: formStateObject.description, + modifiedBy: formStateObject.modifiedBy, + createdBy: formStateObject.createdBy, + }; + + PostV2ScreenGroups({ + screenGroupScreenGroupInput: JSON.stringify(saveData), + }); + }; + + return ( +
+ +
+ ); +} + +export default GroupCreate; diff --git a/assets/admin/components/groups/group-edit.jsx b/assets/admin/components/groups/group-edit.jsx new file mode 100644 index 000000000..7009f08f8 --- /dev/null +++ b/assets/admin/components/groups/group-edit.jsx @@ -0,0 +1,122 @@ +import { React, useEffect, useState } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { + useGetV2ScreenGroupsByIdQuery, + usePutV2ScreenGroupsByIdMutation, +} from "../../redux/api/api.generated.ts"; +import { + displaySuccess, + displayError, +} from "../util/list/toast-component/display-toast"; +import GroupForm from "./group-form"; + +/** + * The group edit component. + * + * @returns {object} The group edit page. + */ +function GroupEdit() { + const { t } = useTranslation("common", { keyPrefix: "group-edit" }); + const navigate = useNavigate(); + const headerText = t("edit-group-header"); + const [formStateObject, setFormStateObject] = useState(); + const [savingGroup, setSavingGroup] = useState(false); + const [loadingMessage, setLoadingMessage] = useState( + t("loading-messages.loading-group") + ); + const { id } = useParams(); + const [PutV2ScreenGroup, { error: saveError, isSuccess: isSaveSuccess }] = + usePutV2ScreenGroupsByIdMutation(); + + const { + data, + error: loadError, + isLoading, + } = useGetV2ScreenGroupsByIdQuery({ id }); + + /** Set loaded data into form state. */ + useEffect(() => { + if (data) { + setFormStateObject(data); + } + }, [data]); + + /** If the group is saved, display the success message */ + useEffect(() => { + if (isSaveSuccess) { + setSavingGroup(false); + displaySuccess(t("success-messages.saved-group")); + navigate("/group/list"); + } + }, [isSaveSuccess]); + + /** If the group is saved with error, display the error message */ + useEffect(() => { + if (saveError) { + displayError(t("error-messages.save-group-error"), saveError); + setSavingGroup(false); + } + }, [saveError]); + + /** If the group is not loaded, display the error message */ + useEffect(() => { + if (loadError) { + displayError( + t("error-messages.load-group-error", { + error: loadError.error + ? loadError.error + : loadError.data["hydra:description"], + id, + }) + ); + } + }, [loadError]); + + /** + * Set state on change in input field + * + * @param {object} props The props. + * @param {object} props.target Event target. + */ + const handleInput = ({ target }) => { + const localFormStateObject = { ...formStateObject }; + localFormStateObject[target.id] = target.value; + setFormStateObject(localFormStateObject); + }; + + /** Handles submit. */ + const handleSubmit = () => { + setSavingGroup(true); + setLoadingMessage(t("loading-messages.saving-group")); + const saveData = { + title: formStateObject.title, + description: formStateObject.description, + modifiedBy: formStateObject.modifiedBy, + createdBy: formStateObject.createdBy, + }; + PutV2ScreenGroup({ + id, + screenGroupScreenGroupInput: JSON.stringify(saveData), + }); + }; + + return ( +
+ {formStateObject && ( + + )} +
+ ); +} + +export default GroupEdit; diff --git a/assets/admin/components/groups/group-form.jsx b/assets/admin/components/groups/group-form.jsx new file mode 100644 index 000000000..c3a5e5ec1 --- /dev/null +++ b/assets/admin/components/groups/group-form.jsx @@ -0,0 +1,100 @@ +import { React } from "react"; +import { useNavigate } from "react-router-dom"; +import { Button } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; +import PropTypes from "prop-types"; +import Form from "react-bootstrap/Form"; +import LoadingComponent from "../util/loading-component/loading-component"; +import ContentBody from "../util/content-body/content-body"; +import ContentFooter from "../util/content-footer/content-footer"; +import FormInput from "../util/forms/form-input"; + +/** + * The group form component. + * + * @param {object} props - The props. + * @param {object} props.group The group object to modify in the form. + * @param {Function} props.handleInput Handles form input. + * @param {Function} props.handleSubmit Handles form submit. + * @param {string} props.headerText Headline text. + * @param {boolean} props.isLoading Indicator of whether the form is loading + * @param {string} props.loadingMessage The loading message for the spinner + * @returns {object} The group form. + */ +function GroupForm({ + handleInput, + handleSubmit, + headerText, + isLoading = false, + loadingMessage = "", + group = { + description: "", + title: "", + }, +}) { + const { t } = useTranslation("common"); + const navigate = useNavigate(); + + return ( + <> + +
+

{headerText}

+ + + + + + + + +
+ + ); +} + +GroupForm.propTypes = { + group: PropTypes.shape({ + description: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + }), + handleInput: PropTypes.func.isRequired, + handleSubmit: PropTypes.func.isRequired, + headerText: PropTypes.string.isRequired, + isLoading: PropTypes.bool, + loadingMessage: PropTypes.string, +}; + +export default GroupForm; diff --git a/assets/admin/components/groups/groups-columns.jsx b/assets/admin/components/groups/groups-columns.jsx new file mode 100644 index 000000000..ce222287c --- /dev/null +++ b/assets/admin/components/groups/groups-columns.jsx @@ -0,0 +1,41 @@ +import { React } from "react"; +import { useTranslation } from "react-i18next"; +import ListButton from "../util/list/list-button"; +import ColumnHoc from "../util/column-hoc"; +import SelectColumnHoc from "../util/select-column-hoc"; + +/** + * Columns for group lists. + * + * @param {object} props - The props. + * @param {Function} props.apiCall - The api to call + * @param {string} props.infoModalRedirect - The url for redirecting in the info modal. + * @param {string} props.infoModalTitle - The info modal title. + * @returns {object} The columns for the group lists. + */ +function getGroupColumns({ apiCall, infoModalRedirect, infoModalTitle }) { + const { t } = useTranslation("common", { keyPrefix: "groups-columns" }); + + const columns = [ + { + // eslint-disable-next-line react/prop-types + content: ({ screens }) => ( + + ), + key: "screens", + label: t("screens"), + }, + ]; + + return columns; +} + +const GroupColumns = ColumnHoc(getGroupColumns); +const SelectGroupColumns = SelectColumnHoc(getGroupColumns); + +export { SelectGroupColumns, GroupColumns }; diff --git a/assets/admin/components/groups/groups-list.jsx b/assets/admin/components/groups/groups-list.jsx new file mode 100644 index 000000000..1031a7901 --- /dev/null +++ b/assets/admin/components/groups/groups-list.jsx @@ -0,0 +1,153 @@ +import { React, useEffect, useState, useContext } from "react"; +import { useTranslation } from "react-i18next"; +import List from "../util/list/list"; +import ListContext from "../../context/list-context"; +import UserContext from "../../context/user-context"; +import useModal from "../../context/modal-context/modal-context-hook"; +import { GroupColumns } from "./groups-columns"; +import ContentHeader from "../util/content-header/content-header"; +import ContentBody from "../util/content-body/content-body"; +import idFromUrl from "../util/helpers/id-from-url"; +import { + displaySuccess, + displayError, +} from "../util/list/toast-component/display-toast"; +import { + useGetV2ScreenGroupsQuery, + useGetV2ScreenGroupsByIdScreensQuery, + useDeleteV2ScreenGroupsByIdMutation, +} from "../../redux/api/api.generated.ts"; + +/** + * The groups list component. + * + * @returns {object} The groups list. + */ +function GroupsList() { + const { t } = useTranslation("common", { keyPrefix: "groups-list" }); + const { selected, setSelected } = useModal(); + const { + searchText: { get: searchText }, + page: { get: page }, + createdBy: { get: createdBy }, + } = useContext(ListContext); + const context = useContext(UserContext); + + // Local state + const [isDeleting, setIsDeleting] = useState(false); + const [listData, setListData] = useState(); + const [loadingMessage, setLoadingMessage] = useState( + t("loading-messages.loading-groups") + ); + + // Delete call + const [ + DeleteV2ScreenGroups, + { isSuccess: isDeleteSuccess, error: isDeleteError }, + ] = useDeleteV2ScreenGroupsByIdMutation(); + + // Get method + const { + data, + error: groupsGetError, + isLoading, + refetch, + } = useGetV2ScreenGroupsQuery({ + page, + order: { createdAt: "desc" }, + title: searchText, + createdBy, + }); + + useEffect(() => { + if (data) { + setListData(data); + } + }, [data]); + + useEffect(() => { + refetch(); + }, [searchText, page, createdBy]); + + /** Deletes multiple groups. */ + useEffect(() => { + if (isDeleting && selected.length > 0) { + const groupToDelete = selected[0]; + setSelected(selected.slice(1)); + const groupToDeleteId = idFromUrl(groupToDelete.id); + DeleteV2ScreenGroups({ id: groupToDeleteId }); + } + }, [isDeleting, isDeleteSuccess]); + + // Sets success messages in local storage, because the page is reloaded + useEffect(() => { + if (isDeleteSuccess && selected.length === 0) { + displaySuccess(t("success-messages.group-delete")); + refetch(); + setIsDeleting(false); + } + }, [isDeleteSuccess]); + + // If the tenant is changed, data should be refetched + useEffect(() => { + if (context.selectedTenant.get) { + refetch(); + } + }, [context.selectedTenant.get]); + + // Display error on unsuccessful deletion + useEffect(() => { + if (isDeleteError) { + setIsDeleting(false); + displayError(t("error-messages.group-delete-error"), isDeleteError); + } + }, [isDeleteError]); + + /** Starts the deletion process. */ + const handleDelete = () => { + setIsDeleting(true); + setLoadingMessage(t("loading-messages.deleting-group")); + }; + + // The columns for the table. + const columns = GroupColumns({ + handleDelete, + apiCall: useGetV2ScreenGroupsByIdScreensQuery, + infoModalRedirect: "/screen/edit", + infoModalTitle: t("info-modal.screens"), + }); + + // Error with retrieving list of groups + useEffect(() => { + if (groupsGetError) { + displayError(t("error-messages.groups-load-error"), groupsGetError); + } + }, [groupsGetError]); + + return ( +
+ + + <> + {listData && ( + + )} + + +
+ ); +} + +export default GroupsList; diff --git a/assets/admin/components/media-modal/media-modal.jsx b/assets/admin/components/media-modal/media-modal.jsx new file mode 100644 index 000000000..e30450bf4 --- /dev/null +++ b/assets/admin/components/media-modal/media-modal.jsx @@ -0,0 +1,60 @@ +import { React, useEffect } from "react"; +import PropTypes from "prop-types"; +import { useTranslation } from "react-i18next"; +import Modal from "react-bootstrap/Modal"; +import ModalDialog from "../util/modal/modal-dialog"; +import MediaList from "../media/media-list"; +import useModal from "../../context/modal-context/modal-context-hook"; + +/** + * Media modal component. + * + * @param {object} props Props. + * @param {boolean} props.show Whether to show the modal. + * @param {Function} props.onClose Callback on close modal. + * @param {Function} props.handleAccept The are you sure you want to delete text. + * @param {boolean} props.multiple Whether it should be possible to choose + * multiple images. + * @returns {object} The modal. + */ +function MediaModal({ show, onClose, handleAccept, multiple }) { + const { t } = useTranslation("common"); + const { selected } = useModal(); + + if (!show) { + return <>; + } + + useEffect(() => { + if (selected && selected.length > 0) { + handleAccept(selected); + } + }, [selected]); + + return ( + + handleAccept(selected)} + > + + + + ); +} + +MediaModal.propTypes = { + show: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + handleAccept: PropTypes.func.isRequired, + multiple: PropTypes.bool.isRequired, +}; + +export default MediaModal; diff --git a/assets/admin/components/media/image-list.jsx b/assets/admin/components/media/image-list.jsx new file mode 100644 index 000000000..6850bd1d0 --- /dev/null +++ b/assets/admin/components/media/image-list.jsx @@ -0,0 +1,92 @@ +import { React } from "react"; +import PropTypes from "prop-types"; +import { useTranslation } from "react-i18next"; +import { Form } from "react-bootstrap"; +import selectedHelper from "../util/helpers/selected-helper"; +import useModal from "../../context/modal-context/modal-context-hook"; +import ListLoading from "../util/loading-component/list-loading"; + +/** + * The image list component. + * + * @param {object} props Props. + * @param {Array} props.media List of media elements + * @param {boolean} props.multiple Whether the image list allows for multiselect + * @returns {object} The image list page. + */ +function ImageList({ media = [], multiple }) { + const { selected, setSelected } = useModal(); + + /** + * Select image function + * + * @param {Array} data - The image/images. + */ + function selectImage(data) { + const selectedImages = selectedHelper(data, [...selected]); + if (multiple) { + setSelected(selectedImages); + } else { + setSelected([selectedImages[0]]); + } + } + + // Translations + const { t } = useTranslation("common", { keyPrefix: "media-list" }); + + return ( +
+ {media.map((data) => ( +
+
item.id === data["@id"]) + ? " selected" + : "" + }`} + > + {data.description} + selectImage(data)} + checked={selected.find((item) => item.id === data["@id"])} + aria-label={t("checkbox-form-aria-label", { this: data.title })} + /> +
+
+
+

{data.name}

+
+
+
+
+ {data.description} +
+
+
+
+
+ ))} +
+ ); +} + +ImageList.propTypes = { + multiple: PropTypes.bool.isRequired, + media: PropTypes.arrayOf( + PropTypes.shape({ + name: PropTypes.string, + selected: PropTypes.bool, + "@id": PropTypes.string, + description: PropTypes.string, + assets: PropTypes.shape({ uri: PropTypes.string }), + }) + ), +}; + +export default ListLoading(ImageList); diff --git a/assets/admin/components/media/media-create.jsx b/assets/admin/components/media/media-create.jsx new file mode 100644 index 000000000..15c48b388 --- /dev/null +++ b/assets/admin/components/media/media-create.jsx @@ -0,0 +1,104 @@ +import { React, useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { usePostMediaCollectionMutation } from "../../redux/api/api.generated.ts"; +import MediaForm from "./media-form"; +import { + displayError, + displaySuccess, +} from "../util/list/toast-component/display-toast"; + +/** + * The create media component. + * + * @returns {object} The create media page. + */ +function MediaCreate() { + const { t } = useTranslation("common", { keyPrefix: "media-create" }); + const [formStateObject, setFormStateObject] = useState({}); + const [isSaving, setIsSaving] = useState(false); + const [loadingMessage, setLoadingMessage] = useState(""); + const [mediaToCreate, setMediaToCreate] = useState([]); + const headerText = t("create-media"); + + const [ + PostV2MediaCollection, + { isLoading: isSavingMedia, error: saveError, isSuccess: isSaveSuccess }, + ] = usePostMediaCollectionMutation(); + + /** + * Set state on change in input field + * + * @param {object} props The props. + * @param {object} props.target Event target + */ + const handleInput = ({ target }) => { + const localFormStateObject = { ...formStateObject }; + localFormStateObject[target.id] = target.value; + setFormStateObject(localFormStateObject); + }; + + /** Saves multiple pieces of media. */ + useEffect(() => { + if (mediaToCreate.length > 0) { + setIsSaving(true); + const media = mediaToCreate.splice(0, 1).shift(); + PostV2MediaCollection({ body: media }); + } + }, [mediaToCreate.length, isSaveSuccess]); + + /** If the media is saved with error, display the error message */ + useEffect(() => { + if (saveError) { + displayError(t("error-messages.save-media-error"), saveError); + setIsSaving(false); + } + }, [saveError]); + + /** If the image is saved, display the success message, and remove image from ui */ + useEffect(() => { + if (isSaveSuccess) { + displaySuccess(t("success-messages.saved-media")); + setIsSaving(false); + const localFormStateObject = JSON.parse(JSON.stringify(formStateObject)); + localFormStateObject.images = []; + setFormStateObject(localFormStateObject); + } + }, [isSaveSuccess]); + + /** Handles submit. */ + const handleSubmit = () => { + const localMediaToCreate = []; + formStateObject.images.forEach((element) => { + setLoadingMessage( + t("loading-messages.saving-media", { + title: element.title || t("unamed"), + }) + ); + const formData = new FormData(); + formData.append("file", element.file); + formData.append("title", element.title); + formData.append("description", element.description); + formData.append("license", element.license); + formData.append("modifiedBy", element.modfiedBy); + formData.append("createdBy", element.createdBy); + localMediaToCreate.push(formData); + }); + + setMediaToCreate(localMediaToCreate); + }; + + return ( + + ); +} + +export default MediaCreate; diff --git a/assets/admin/components/media/media-form.jsx b/assets/admin/components/media/media-form.jsx new file mode 100644 index 000000000..1f896cc0f --- /dev/null +++ b/assets/admin/components/media/media-form.jsx @@ -0,0 +1,104 @@ +import { React } from "react"; +import { useNavigate } from "react-router-dom"; +import { Button } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; +import PropTypes from "prop-types"; +import Form from "react-bootstrap/Form"; +import LoadingComponent from "../util/loading-component/loading-component"; +import ContentBody from "../util/content-body/content-body"; +import ContentFooter from "../util/content-footer/content-footer"; +import ImageUploader from "../util/image-uploader/image-uploader"; + +/** + * The media form component. + * + * @param {object} props - The props. + * @param {object} props.media The media object to modify in the form. + * @param {Function} props.handleInput Handles form input. + * @param {Function} props.handleSubmit Handles form submit. + * @param {string} props.headerText Headline text. + * @param {Array} props.errors Array of errors. + * @param {boolean} props.isLoading Indicator of whether the form is loading + * @param {string} props.loadingMessage The loading message for the spinner + * @returns {object} The slide form. + */ +function MediaForm({ + media, + handleInput, + handleSubmit, + headerText, + errors, + isLoading = false, + loadingMessage = "", +}) { + const { t } = useTranslation("common"); + const navigate = useNavigate(); + + return ( + <> +
+ +

{headerText}

+ + + + + + + + + + + ); +} + +MediaForm.propTypes = { + media: PropTypes.arrayOf( + PropTypes.shape({ + url: PropTypes.string, + }) + ).isRequired, + handleInput: PropTypes.func.isRequired, + handleSubmit: PropTypes.func.isRequired, + headerText: PropTypes.string.isRequired, + errors: PropTypes.oneOfType([ + PropTypes.objectOf(PropTypes.any), + PropTypes.bool, + ]).isRequired, + isLoading: PropTypes.bool, + loadingMessage: PropTypes.string, +}; + +export default MediaForm; diff --git a/assets/admin/components/media/media-list.jsx b/assets/admin/components/media/media-list.jsx new file mode 100644 index 000000000..9009158b0 --- /dev/null +++ b/assets/admin/components/media/media-list.jsx @@ -0,0 +1,245 @@ +import { React, useEffect, useState, useContext } from "react"; +import PropTypes from "prop-types"; +import { Button, Col, Row } from "react-bootstrap"; +import { Link, useNavigate, useLocation } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import UserContext from "../../context/user-context"; +import SearchBox from "../util/search-box/search-box"; +import ContentBody from "../util/content-body/content-body"; +import idFromUrl from "../util/helpers/id-from-url"; +import Pagination from "../util/paginate/pagination"; +import ImageList from "./image-list"; +import useModal from "../../context/modal-context/modal-context-hook"; +import { + displayError, + displaySuccess, +} from "../util/list/toast-component/display-toast"; +import { + useGetV2MediaQuery, + useDeleteV2MediaByIdMutation, +} from "../../redux/api/api.generated.ts"; +import FormCheckbox from "../util/forms/form-checkbox"; +import "./media-list.scss"; + +/** + * The media list component. + * + * @param {object} props The props. + * @param {boolean} props.fromModal Whether it is opened from the modal, if it + * is, the upload and delete function should not be accesible. + * @param {boolean} props.multiple Whether the image list allows for multiselect + * @returns {object} The media list. + */ +function MediaList({ fromModal = false, multiple = true }) { + // Translations + const { t } = useTranslation("common", { keyPrefix: "media-list" }); + // Selected data + const { selected, setSelected, setModal } = useModal(); + + // Context + const context = useContext(UserContext); + + // Url params + const { search } = useLocation(); + const pageParams = new URLSearchParams(search).get("page"); + const searchParams = new URLSearchParams(search).get("search"); + + // Misc + const navigate = useNavigate(); + const pageSize = 10; + + // State + const [sortDesc, setSortDesc] = useState(true); + const [isDeleting, setIsDeleting] = useState(false); + const [totalItems, setTotalItems] = useState(0); + const [media, setMedia] = useState([]); + const [page, setPage] = useState(parseInt(pageParams || 1, 10)); + const [searchText, setSearchText] = useState( + searchParams === null ? "" : searchParams + ); + const [loadingMessage, setLoadingMessage] = useState( + t("loading-messages.loading-media") + ); + + // Delete method + const [DeleteV2Media, { isSuccess: isDeleteSuccess, error: isDeleteError }] = + useDeleteV2MediaByIdMutation(); + + // Get method + const { + data: mediaData, + error: mediaLoadError, + + isLoading, + refetch, + } = useGetV2MediaQuery({ + page, + title: searchText, + order: { createdAt: sortDesc ? "desc" : "asc" }, + }); + + /** Set loaded data into form state. */ + useEffect(() => { + if (mediaData) { + const mappedData = mediaData["hydra:member"].map((mediaItem) => { + return { + selected: false, + ...mediaItem, + }; + }); + setMedia(mappedData); + setTotalItems(mediaData["hydra:totalItems"]); + } + }, [mediaData]); + + // If the tenant is changed, data should be refetched + useEffect(() => { + if (context.selectedTenant.get) { + refetch(); + } + }, [context.selectedTenant.get]); + + /** @param {number} nextPage - The next page. */ + const updateUrlAndChangePage = (nextPage) => { + const params = new URLSearchParams(search); + params.delete("page"); + params.append("page", nextPage); + navigate({ search: params.toString() }); + setPage(nextPage); + }; + + /** Sets the url. */ + useEffect(() => { + if (!fromModal) { + const params = new URLSearchParams(search); + params.delete("search"); + params.append("search", searchText); + params.delete("page"); + params.append("page", page); + navigate({ search: params.toString() }); + } + }, [searchText]); + + /** + * Sets search text. + * + * @param {string} newSearchText Updates the search text state and url. + */ + const handleSearch = (newSearchText) => { + setPage(1); + setSearchText(newSearchText); + }; + + /** Deletes multiple pieces of media. */ + useEffect(() => { + if (isDeleting && selected.length > 0) { + const toDelete = selected[0]; + setSelected(selected.slice(1)); + const toDeleteId = idFromUrl(toDelete.id); + DeleteV2Media({ id: toDeleteId }); + } + }, [isDeleting, isDeleteSuccess]); + + // Display success messages + useEffect(() => { + if (isDeleteSuccess && selected.length === 0) { + displaySuccess(t("success-messages.media-delete")); + setIsDeleting(false); + refetch(); + } + }, [isDeleteSuccess]); + + // Display error on unsuccessful deletion + useEffect(() => { + if (isDeleteError) { + setIsDeleting(false); + displayError(t("error-messages.media-delete-error"), isDeleteError); + } + }, [isDeleteError]); + + /** Deletes selected data, and closes modal. */ + const handleDelete = () => { + setLoadingMessage(t("loading-messages.deleting-media")); + setIsDeleting(true); + }; + + useEffect(() => { + if (mediaLoadError) { + displayError(t("error-messages.media-load-error"), mediaLoadError); + } + }, [mediaLoadError]); + + const changeSort = ({ target }) => { + setSortDesc(target.value); + }; + + return ( +
+ + +

{t("header")}

+ + {!fromModal && ( + <> + + + {t("upload-new-media")} + + + +
+ +
+ + + )} +
+ + + + + + + + + + + + +
+ ); +} + +MediaList.propTypes = { + fromModal: PropTypes.bool, + multiple: PropTypes.bool, +}; + +export default MediaList; diff --git a/assets/admin/components/media/media-list.scss b/assets/admin/components/media/media-list.scss new file mode 100644 index 000000000..ba0b85e47 --- /dev/null +++ b/assets/admin/components/media/media-list.scss @@ -0,0 +1,56 @@ +@use "bootstrap/scss/bootstrap" as *; + +.media-item { + img { + width: 100%; + object-fit: cover; + vertical-align: baseline; + transition: opacity 0.1s ease-out; + height: 150px; + + @include media-breakpoint-up(md) { + height: 200px; + } + @include media-breakpoint-up(lg) { + height: 150px; + } + @include media-breakpoint-up(xxl) { + height: 200px; + } + } + + &.selected { + border-color: $primary; + + img { + opacity: 0.8; + filter: grayscale(50%); + } + } + + .form-check { + position: absolute; + top: $spacer * 0.5; + right: $spacer * 0.5; + + input { + &:focus { + // override bootstrap to make focus more visible on images. + border: 3px solid #86b7fe; + box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.65); + } + } + } +} + +.slide-media-modal { + height: 100vh; + + .modal-content { + height: 100vh; + } + + .modal-body { + overflow: scroll; + } +} diff --git a/assets/admin/components/navigation/login-sidebar/login-sidebar.jsx b/assets/admin/components/navigation/login-sidebar/login-sidebar.jsx new file mode 100644 index 000000000..aa6565bd3 --- /dev/null +++ b/assets/admin/components/navigation/login-sidebar/login-sidebar.jsx @@ -0,0 +1,25 @@ +import { React } from "react"; +import { useTranslation, Trans } from "react-i18next"; +import Logo from "../logo"; + +const LoginSidebar = () => { + const { t } = useTranslation("common", { keyPrefix: "login-sidebar" }); + + return ( +
+ +
+
+

+ {t("internal-info-text")} +

+

+ {t("external-info-text")} +

+
+
+
+ ); +}; + +export default LoginSidebar; diff --git a/assets/admin/components/navigation/logo.jsx b/assets/admin/components/navigation/logo.jsx new file mode 100644 index 000000000..5f1c0bc47 --- /dev/null +++ b/assets/admin/components/navigation/logo.jsx @@ -0,0 +1,12 @@ +import { React } from "react"; +import DisplayLogo from "./logo.svg"; + +const Logo = () => { + return ( +
+ +
+ ); +}; + +export default Logo; diff --git a/assets/admin/components/navigation/logo.svg b/assets/admin/components/navigation/logo.svg new file mode 100644 index 000000000..5c9817c0e --- /dev/null +++ b/assets/admin/components/navigation/logo.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/assets/admin/components/navigation/nav-items/nav-items.jsx b/assets/admin/components/navigation/nav-items/nav-items.jsx new file mode 100644 index 000000000..13155f01d --- /dev/null +++ b/assets/admin/components/navigation/nav-items/nav-items.jsx @@ -0,0 +1,233 @@ +import { React, useContext, useEffect } from "react"; +import { Nav } from "react-bootstrap"; +import { Link, NavLink, useLocation } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faDesktop, + faUsers, + faStream, + faPhotoVideo, + faPlusCircle, + faCog, +} from "@fortawesome/free-solid-svg-icons"; +import ListContext from "../../../context/list-context"; +import UserContext from "../../../context/user-context"; +import useModal from "../../../context/modal-context/modal-context-hook"; +import RestrictedNavRoute from "./restricted-nav-route"; +import "./nav-items.scss"; + +/** + * The nav items. + * + * @returns {object} Nav items + */ +function NavItems() { + const { t } = useTranslation("common", { keyPrefix: "nav-items" }); + const { setSelected } = useModal(); + const { page, createdBy, isPublished } = useContext(ListContext); + const { pathname } = useLocation(); + const { + accessConfig: { get: accessConfig }, + } = useContext(UserContext); + + // Reset list context and selected on page change. + useEffect(() => { + setSelected([]); + if (page) { + page.set(1); + } + if (createdBy) { + createdBy.set("all"); + } + if (isPublished) { + isPublished.set(undefined); + } + }, [pathname]); + + return ( + <> + + `nav-link ${isActive ? "disabled" : ""}`} + to="/slide/list" + > + + {t("content-slides")} + + + + + + + `nav-link ${isActive ? "disabled" : ""}`} + to="/media/list" + > + {t("content-media")} + + + + `nav-link ${isActive ? "disabled" : ""}`} + to="/playlist/list" + > + + {t("playlists-playlists")} + + + + + + {accessConfig?.campaign?.roles && ( + <> + + + + `nav-link ${isActive ? "disabled" : ""}` + } + to="/campaign/list" + > + {t("playlists-campaigns")} + + + + + )} + {accessConfig?.shared?.roles && ( + <> + + + + `nav-link ${isActive ? "disabled" : ""}` + } + to="/shared/list" + > + {t("shared-playlists")} + + + + + )} + {accessConfig?.campaign?.roles && ( + <> + + + + `nav-link ${isActive ? "disabled" : ""}` + } + to="/screen/list" + > + + {t("screens-screens")} + + + + + + + + )} + {accessConfig?.groups?.roles && ( + <> + + + + `nav-link ${isActive ? "disabled" : ""}` + } + to="/group/list" + > + {t("screens-groups")} + + + + + )} + {accessConfig?.users?.roles && ( + <> + + + + `nav-link ${isActive ? "disabled" : ""}` + } + to="/users/list" + > + + {t("users")} + + + + + + + `nav-link ${isActive ? "disabled" : ""}` + } + to="/activation/list" + > + {t("activation-codes")} + + + + + )} + {accessConfig?.settings?.roles && ( + <> + + + + `nav-link ${isActive ? "disabled" : ""}` + } + > + + {t("configuration")} + + + + + + + `nav-link ${isActive ? "disabled" : ""}` + } + to="/themes/list" + > + {t("configuration-themes")} + + + + + `nav-link ${isActive ? "disabled" : ""}` + } + to="/feed-sources/list" + > + {t("configuration-feedsources")} + + + + + )} + + ); +} + +export default NavItems; diff --git a/assets/admin/components/navigation/nav-items/nav-items.scss b/assets/admin/components/navigation/nav-items/nav-items.scss new file mode 100644 index 000000000..b07234da2 --- /dev/null +++ b/assets/admin/components/navigation/nav-items/nav-items.scss @@ -0,0 +1,25 @@ +@use "sass:math"; +@use "bootstrap/scss/bootstrap" as *; + +.nav-item { + .nav-link { + padding-top: $spacer; + padding-bottom: $spacer; + + &.disabled { + font-weight: bold; + } + } +} + +.nav-second-level { + font-size: 0.9rem; + opacity: 0.8; + + .nav-link { + padding-left: $spacer * 3; + $padding: math.div($spacer, 2); + padding-top: $padding; + padding-bottom: $padding; + } +} diff --git a/assets/admin/components/navigation/nav-items/restricted-nav-route.jsx b/assets/admin/components/navigation/nav-items/restricted-nav-route.jsx new file mode 100644 index 000000000..d56c8f374 --- /dev/null +++ b/assets/admin/components/navigation/nav-items/restricted-nav-route.jsx @@ -0,0 +1,33 @@ +import { React, useContext } from "react"; +import PropTypes from "prop-types"; +import UserContext from "../../../context/user-context"; + +/** + * The restricted nav route wrapper. + * + * @param {object} props - The props. + * @param {Array} props.children The children being passed from parent + * @param {Array} props.roles - The list of roles that have access to the nav route. + * @returns {object} Nothing or the children. + */ +function RestrictedNavRoute({ children, roles }) { + const context = useContext(UserContext); + + // If the user has a role with access to children. + const userHasRequiredRole = context.selectedTenant.get?.roles.find((value) => + roles.includes(value) + ); + + if (!userHasRequiredRole) { + return <>; + } + + return children; +} + +RestrictedNavRoute.propTypes = { + children: PropTypes.node.isRequired, + roles: PropTypes.arrayOf(PropTypes.string).isRequired, +}; + +export default RestrictedNavRoute; diff --git a/assets/admin/components/navigation/nav-items/tenant-selector.jsx b/assets/admin/components/navigation/nav-items/tenant-selector.jsx new file mode 100644 index 000000000..7035a0ba9 --- /dev/null +++ b/assets/admin/components/navigation/nav-items/tenant-selector.jsx @@ -0,0 +1,70 @@ +import { React, useContext } from "react"; +import { Nav, NavDropdown } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; +import UserContext from "../../../context/user-context"; +import localStorageKeys from "../../util/local-storage-keys"; +import "./tenant-selector.scss"; + +/** + * The TenantSelector component. + * + * @returns {object} The TenantSelector + */ +function TenantSelector() { + const { + tenants: { get: tenants }, + selectedTenant: { set: setSelectedTenant, get: selectedTenant }, + } = useContext(UserContext); + const { t } = useTranslation("common", { keyPrefix: "tenant-selector" }); + + /** + * Change tenant on select tenant + * + * @param {object} props - The props. + * @param {object} props.target Event target + */ + function onTenantChange({ target }) { + const newTenant = tenants.find(({ tenantKey }) => tenantKey === target.id); + setSelectedTenant(newTenant); + localStorage.setItem( + localStorageKeys.SELECTED_TENANT, + JSON.stringify(newTenant) + ); + } + + return ( + +
{t("tenant")}
+
+ {tenants && ( + <> + {tenants.length === 1 && ( +
{selectedTenant.title}
+ )} + {tenants.length > 1 && ( + + {tenants.map(({ tenantKey, title }) => ( + onTenantChange(target)} + id={tenantKey} + key={tenantKey} + > + {title} + + ))} + + )} + + )} +
+
+ ); +} + +export default TenantSelector; diff --git a/assets/admin/components/navigation/nav-items/tenant-selector.scss b/assets/admin/components/navigation/nav-items/tenant-selector.scss new file mode 100644 index 000000000..b8acbc16e --- /dev/null +++ b/assets/admin/components/navigation/nav-items/tenant-selector.scss @@ -0,0 +1,20 @@ +.tenant-dropdown .dropdown-toggle::after { + float: right; + margin-top: 0.75em; +} + +.tenant-dropdown.nav-item.dropdown { + border: 1px solid white; + border-radius: 5px; + margin-top: 5px; + width: 93%; +} + +.tenant-dropdown .dropdown-toggle.nav-link { + padding-top: 0.5em; + padding-bottom: 0.5em; +} + +.tenant-dropdown .dropdown-menu.show { + width: 100%; +} diff --git a/assets/admin/components/navigation/sidebar/sidebar.jsx b/assets/admin/components/navigation/sidebar/sidebar.jsx new file mode 100644 index 000000000..e0b89dd66 --- /dev/null +++ b/assets/admin/components/navigation/sidebar/sidebar.jsx @@ -0,0 +1,44 @@ +import { React, useState } from "react"; +import Nav from "react-bootstrap/Nav"; +import Brand from "react-bootstrap/NavbarBrand"; +import Col from "react-bootstrap/Col"; +import NavItems from "../nav-items/nav-items"; +import "./sidebar.scss"; +import Logo from "../logo"; +import TenantSelector from "../nav-items/tenant-selector"; + +/** + * The sidebar component. + * + * @returns {object} The SideBar + */ +function SideBar() { + const [active, setActive] = useState(); + + return ( + + + + ); +} + +export default SideBar; diff --git a/assets/admin/components/navigation/sidebar/sidebar.scss b/assets/admin/components/navigation/sidebar/sidebar.scss new file mode 100644 index 000000000..9705bdd71 --- /dev/null +++ b/assets/admin/components/navigation/sidebar/sidebar.scss @@ -0,0 +1,37 @@ +@use "bootstrap/scss/bootstrap" as *; + +.sidebar-nav { + &.nav-dark { + .nav-item { + margin-top: $spacer; + position: relative; + width: 100%; + .nav-add-new { + color: white; + position: absolute; + top: 16px; + right: 10px; + &:hover { + color: $primary; + } + } + .nav-link { + color: white; + &:hover { + background-color: $gray-700; + transition: background-color 0.2s ease-in-out; + .nav-link { + color: $gray-100; + } + } + &.active { + background-color: $gray-800; + border-right: 3px solid $primary; + } + } + &.nav-second-level { + margin-top: 0; + } + } + } +} diff --git a/assets/admin/components/navigation/topbar/top-bar.jsx b/assets/admin/components/navigation/topbar/top-bar.jsx new file mode 100644 index 000000000..f68b734de --- /dev/null +++ b/assets/admin/components/navigation/topbar/top-bar.jsx @@ -0,0 +1,123 @@ +import { React, useContext } from "react"; +import Nav from "react-bootstrap/Nav"; +import Dropdown from "react-bootstrap/Dropdown"; +import Navbar from "react-bootstrap/Navbar"; +import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faPhotoVideo, + faPlusCircle, + faProjectDiagram, + faDesktop, + faStream, + faSignOutAlt, +} from "@fortawesome/free-solid-svg-icons"; +import NavItems from "../nav-items/nav-items"; +import UserContext from "../../../context/user-context"; + +import "./top-bar.scss"; + +/** + * The top bar navigation component. + * + * @returns {object} The top bar navigation component + */ +function TopBar() { + const { t } = useTranslation("common"); + const context = useContext(UserContext); + + return ( + + + {t("topbar.brand")} + +
{context.userName.get}
+ + + + + +
+ ); +} + +export default TopBar; diff --git a/assets/admin/components/navigation/topbar/top-bar.scss b/assets/admin/components/navigation/topbar/top-bar.scss new file mode 100644 index 000000000..f2ff82a4e --- /dev/null +++ b/assets/admin/components/navigation/topbar/top-bar.scss @@ -0,0 +1,43 @@ +.navbar-light { + .navbar-nav { + .nav-second-level::before { + content: "\00a0\00a0\00a0"; + } + .nav-item { + display: flex; + + .nav-add-new { + align-self: center; + } + .nav-link { + color: black; + svg { + display: none; + } + } + } + + .topbar-nav { + &.navbar-nav { + .nav-link { + color: var(--bs-gray-dark); + } + } + } + } + .show { + .navbar-nav { + .add-new-dropdown { + display: none; + } + } + } +} +// Stolen from brand-css +.name { + padding-top: 0.3125rem; + padding-bottom: 0.3125rem; + margin-right: 1rem; + text-decoration: none; + white-space: nowrap; +} diff --git a/assets/admin/components/no-access/no-access.jsx b/assets/admin/components/no-access/no-access.jsx new file mode 100644 index 000000000..994cb65b3 --- /dev/null +++ b/assets/admin/components/no-access/no-access.jsx @@ -0,0 +1,19 @@ +import { React } from "react"; +import { useTranslation } from "react-i18next"; + +/** + * No access component + * + * @returns {object} A no access component. + */ +function NoAccess() { + const { t } = useTranslation("common"); + + return ( + <> +
{t("no-access.you-dont-have-access")}
+ + ); +} + +export default NoAccess; diff --git a/assets/admin/components/playlist-drag-and-drop/playlist-drag-and-drop.jsx b/assets/admin/components/playlist-drag-and-drop/playlist-drag-and-drop.jsx new file mode 100644 index 000000000..a40d6ac49 --- /dev/null +++ b/assets/admin/components/playlist-drag-and-drop/playlist-drag-and-drop.jsx @@ -0,0 +1,109 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { SelectPlaylistColumns } from "../playlist/playlists-columns"; +import PlaylistsDropdown from "../util/forms/multiselect-dropdown/playlists/playlists-dropdown"; +import DragAndDropTable from "../util/drag-and-drop-table/drag-and-drop-table"; +import FormCheckbox from "../util/forms/form-checkbox"; +import { + useGetV2PlaylistsByIdSlidesQuery, + useGetV2PlaylistsQuery, +} from "../../redux/api/api.generated.ts"; +import ScreenGanttChart from "../screen/util/screen-gantt-chart"; + +/** + * A drag and drop component for playlists. + * + * @param {string} props The props. + * @param {Array} props.selectedPlaylists - The selected playlists + * @param {string} props.name - The name + * @param {Function} props.handleChange - The callback when something is added + * @param {string} props.regionId - The region id for get request + * @param {string} props.regionIdForInitializeCallback - The region id to add + * regions to formstateobject. + * @returns {object} A drag and drop component + */ +function PlaylistDragAndDrop({ + selectedPlaylists, + name, + handleChange, + removeFromList, + regionId, +}) { + const { t } = useTranslation("common", { + keyPrefix: "playlist-drag-and-drop", + }); + const [searchText, setSearchText] = useState(); + const [page, setPage] = useState(1); + const [onlySharedPlaylists, setOnlySharedPlaylists] = useState(false); + + const { + data: { + "hydra:member": playlists = null, + "hydra:totalItems": totalItems = 0, + } = {}, + } = useGetV2PlaylistsQuery({ + isCampaign: false, + title: searchText, + itemsPerPage: 30, + order: { createdAt: "desc" }, + sharedWithMe: onlySharedPlaylists, + }); + + /** + * Fetches data for the multi component + * + * @param {string} filter - The filter. + */ + const onFilter = (filter) => { + setSearchText(filter); + }; + + const columns = SelectPlaylistColumns({ + handleDelete: removeFromList, + apiCall: useGetV2PlaylistsByIdSlidesQuery, + editTarget: "playlist", + infoModalRedirect: "/slide/edit", + dataKey: "slide", + infoModalTitle: t("select-playlists-table.info-modal.slides"), + }); + + if (!playlists) return null; + + return ( + <> + { + setOnlySharedPlaylists(!onlySharedPlaylists); + }} + value={onlySharedPlaylists} + name="show-only-shared" + /> +
+ +
+ {selectedPlaylists.length > 0 && ( + setPage(page + 1)} + label={t("more-playlists")} + totalItems={totalItems} + /> + )} + {selectedPlaylists?.length > 0 && ( + + )} + + ); +} + +export default PlaylistDragAndDrop; diff --git a/assets/admin/components/playlist/campaign-form.jsx b/assets/admin/components/playlist/campaign-form.jsx new file mode 100644 index 000000000..cb30e33df --- /dev/null +++ b/assets/admin/components/playlist/campaign-form.jsx @@ -0,0 +1,56 @@ +import { React } from "react"; +import { useTranslation } from "react-i18next"; +import PropTypes from "prop-types"; +import idFromUrl from "../util/helpers/id-from-url"; +import { useGetV2CampaignsByIdScreenGroupsQuery } from "../../redux/api/api.generated.ts"; +import ContentBody from "../util/content-body/content-body"; +import SelectScreensTable from "../util/multi-and-table/select-screens-table"; +import SelectGroupsTable from "../util/multi-and-table/select-groups-table"; + +/** + * The campaign form component. + * + * @param {object} props - The props. + * @param {object} props.campaign The campaign object to modify in the form. + * @param {Function} props.handleInput Handles form input. + * @returns {object} The campaign form. + */ +function CampaignForm({ campaign = null, handleInput }) { + const { t } = useTranslation("common"); + + return ( + <> + {campaign && ( + <> + +

{t("campaign-form.title-campaign-screens")}

+ +
+ +

{t("campaign-form.title-campaign-groups")}

+ +
+ + )} + + ); +} + +CampaignForm.propTypes = { + campaign: PropTypes.shape({ + "@id": PropTypes.string, + }), + handleInput: PropTypes.func.isRequired, +}; + +export default CampaignForm; diff --git a/assets/admin/components/playlist/playlist-campaign-create.jsx b/assets/admin/components/playlist/playlist-campaign-create.jsx new file mode 100644 index 000000000..aa89fe53e --- /dev/null +++ b/assets/admin/components/playlist/playlist-campaign-create.jsx @@ -0,0 +1,40 @@ +import { React } from "react"; +import PropTypes from "prop-types"; +import PlaylistCampaignManager from "./playlist-campaign-manager"; + +/** + * The playlist/campaign create component. + * + * @param {object} props Props. + * @param {string} props.location Either playlist or campaign. + * @returns {object} The playlist/campaign create page. + */ +function PlaylistCampaignCreate({ location }) { + // Initialize to empty playlist/campaign object. + const data = { + slides: [], + title: "", + description: "", + modifiedBy: "", + createdBy: "", + schedules: [], + published: { + from: null, + to: null, + }, + }; + + return ( + + ); +} + +PlaylistCampaignCreate.propTypes = { + location: PropTypes.string.isRequired, +}; + +export default PlaylistCampaignCreate; diff --git a/assets/admin/components/playlist/playlist-campaign-edit.jsx b/assets/admin/components/playlist/playlist-campaign-edit.jsx new file mode 100644 index 000000000..5c833e44b --- /dev/null +++ b/assets/admin/components/playlist/playlist-campaign-edit.jsx @@ -0,0 +1,52 @@ +import { React, useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import PropTypes from "prop-types"; +import PlaylistCampaignManager from "./playlist-campaign-manager"; +import idFromUrl from "../util/helpers/id-from-url"; +import { useGetV2PlaylistsByIdQuery } from "../../redux/api/api.generated.ts"; + +/** + * The playlist/campaign edit component. + * + * @param {object} props Props. + * @param {string} props.location Either playlist or campaign. + * @returns {object} The playlist/campaign edit page. + */ +function PlaylistCampaignEdit({ location }) { + const { id } = useParams(); + const [slideId, setSlideId] = useState(); + const { + data, + error: loadingError, + isLoading, + } = useGetV2PlaylistsByIdQuery({ id }); + + /** Sets the id of slides for api call. */ + useEffect(() => { + if (data && !slideId) { + setSlideId(idFromUrl(data.slides)); + } + }, [data]); + + return ( + <> + {(slideId || loadingError) && ( + + )} + + ); +} + +PlaylistCampaignEdit.propTypes = { + location: PropTypes.string.isRequired, +}; + +export default PlaylistCampaignEdit; diff --git a/assets/admin/components/playlist/playlist-campaign-form.jsx b/assets/admin/components/playlist/playlist-campaign-form.jsx new file mode 100644 index 000000000..3ebf6531a --- /dev/null +++ b/assets/admin/components/playlist/playlist-campaign-form.jsx @@ -0,0 +1,339 @@ +import { React, useContext, useState } from "react"; +import { Alert, Button, Col, Form, Row } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import PropTypes from "prop-types"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faExpand } from "@fortawesome/free-solid-svg-icons"; +import ContentBody from "../util/content-body/content-body"; +import FormInput from "../util/forms/form-input"; +import FormInputArea from "../util/forms/form-input-area"; +import SelectSlidesTable from "../util/multi-and-table/select-slides-table"; +import LoadingComponent from "../util/loading-component/loading-component"; +import Preview from "../preview/preview"; +import idFromUrl from "../util/helpers/id-from-url"; +import StickyFooter from "../util/sticky-footer"; +import localStorageKeys from "../util/local-storage-keys"; +import Select from "../util/forms/select"; +import userContext from "../../context/user-context"; + +/** + * The shared form component. + * + * @param {object} props - The props. + * @param {object} props.playlist The playlist object to modify in the form. + * @param {Function} props.handleInput Handles form input. + * @param {Function} props.handleSubmit Handles form submit. + * @param {string} props.headerText Headline text. + * @param {string} props.slideId - The id of the slide. + * @param {boolean} props.isLoading Indicator of whether the form is loading + * @param {string} props.loadingMessage The loading message for the spinner + * @param {boolean} props.isCampaign If it is a campaign form. + * @param {string} props.location Either playlist or campaign. + * @param {Array} props.children The children being passed from parent + * @param {Function} props.handleSaveNoClose Handles form submit with close. + * @returns {object} The form shared by campaigns and playlists. + */ +function PlaylistCampaignForm({ + handleInput, + handleSubmit, + handleSaveNoClose, + headerText, + location, + children, + slideId = "", + isLoading = false, + loadingMessage = "", + isCampaign = false, + playlist = null, +}) { + const { t } = useTranslation("common", { + keyPrefix: "playlist-campaign-form", + }); + const { config } = useContext(userContext); + + const previewOrientationOptions = [ + { + value: "horizontal", + title: t("preview-orientation-landscape"), + key: "horizontal", + }, + { + value: "vertical", + title: t("preview-orientation-portrait"), + key: "vertical", + }, + ]; + const [previewOrientation, setPreviewOrientation] = useState( + previewOrientationOptions[0].value + ); + const navigate = useNavigate(); + const [publishedFromError, setPublishedFromError] = useState(false); + const [publishedToError, setPublishedToError] = useState(false); + const [displayPreview, setDisplayPreview] = useState(null); + const [previewOverlayVisible, setPreviewOverlayVisible] = useState(false); + + /** + * Check if published is set + * + * @param {boolean | null} noRedirect - Save without redirect. + */ + const checkInputsHandleSubmit = (noRedirect) => { + setPublishedToError(false); + setPublishedFromError(false); + let submit = true; + if (isCampaign && !playlist.published.to) { + setPublishedToError(true); + submit = false; + } + if (isCampaign && !playlist.published.from) { + setPublishedFromError(true); + submit = false; + } + + if (submit) { + if (noRedirect === true) { + handleSaveNoClose(); + } else { + handleSubmit(); + } + } + }; + + /** Toggle display preview. */ + const toggleDisplayPreview = () => { + const newValue = !displayPreview; + localStorage.setItem(localStorageKeys.PREVIEW, newValue); + setDisplayPreview(newValue); + }; + + return ( + <> + +
+ +

{headerText}

+ + +

{t("title-about")}

+ + +
+ +

{t("title-slides")}

+ +
+ {/* Playlist or campaign form */} + {children} + +

{t("publish-title")}

+ + + + + + + + + + {t("publish-helptext")} + +
+ + + {displayPreview && ( + +

{t("playlist-preview")}

+
+ + {t("playlist-preview-small-about")} + +
+ + setPreviewOrientation(target.value) + } + required + name="preview-orientation" + options={previewOrientationOptions} + className="m-0" + value={previewOrientation} + /> + + +
+ +
+ {previewOverlayVisible && ( +
+ + + + + + {t("screen-preview-about")} + +
+ )} + + )} +
+ + + + + + {config?.enhancedPreview && ( + + )} + +
+ + ); +} + +export default ScreenForm; diff --git a/assets/admin/components/screen/screen-form.scss b/assets/admin/components/screen/screen-form.scss new file mode 100644 index 000000000..25b605415 --- /dev/null +++ b/assets/admin/components/screen/screen-form.scss @@ -0,0 +1,4 @@ +.resolution-plus-icon { + margin: 45px 6px 0 6px; + padding: 0; +} diff --git a/assets/admin/components/screen/screen-list.jsx b/assets/admin/components/screen/screen-list.jsx new file mode 100644 index 000000000..9308be6c5 --- /dev/null +++ b/assets/admin/components/screen/screen-list.jsx @@ -0,0 +1,165 @@ +import { React, useEffect, useState, useContext } from "react"; +import { useTranslation } from "react-i18next"; +import UserContext from "../../context/user-context"; +import List from "../util/list/list"; +import { ScreenColumns } from "./util/screen-columns"; +import ContentHeader from "../util/content-header/content-header"; +import ContentBody from "../util/content-body/content-body"; +import idFromUrl from "../util/helpers/id-from-url"; +import ListContext from "../../context/list-context"; +import useModal from "../../context/modal-context/modal-context-hook"; +import { + useGetV2ScreensQuery, + useDeleteV2ScreensByIdMutation, + useGetV2ScreensByIdScreenGroupsQuery, +} from "../../redux/api/api.generated.ts"; +import { + displaySuccess, + displayError, +} from "../util/list/toast-component/display-toast"; +import "./screen-list.scss"; +import ConfigLoader from "../../../shared/config-loader.js"; + +/** + * The screen list component. + * + * @returns {object} The screen list. + */ +function ScreenList() { + const { t } = useTranslation("common", { keyPrefix: "screen-list" }); + const context = useContext(UserContext); + const { + searchText: { get: searchText }, + page: { get: page }, + createdBy: { get: createdBy }, + exists: { get: exists }, + screenUserLatestRequest: { get: screenUserLatestRequest }, + } = useContext(ListContext); + const { selected, setSelected } = useModal(); + + // Local state + const [isDeleting, setIsDeleting] = useState(false); + const [listData, setListData] = useState(); + const [loadingMessage, setLoadingMessage] = useState( + t("loading-messages.loading-screens") + ); + const [showScreenStatus, setShowScreenStatus] = useState(false); + + // Delete call + const [ + DeleteV2Screens, + { isSuccess: isDeleteSuccess, error: isDeleteError }, + ] = useDeleteV2ScreensByIdMutation(); + + // Get method + const { + data, + error: screensGetError, + isFetching, + isLoading, + refetch, + } = useGetV2ScreensQuery({ + page, + order: { title: "asc" }, + search: searchText, + createdBy, + exists, + "screenUser.latestRequest": screenUserLatestRequest, + }); + + useEffect(() => { + const config = ConfigLoader.getConfig(); + setShowScreenStatus(config.showScreenStatus); + }, []); + + useEffect(() => { + if (data) { + setListData(data); + } + }, [data]); + + // If the tenant is changed, data should be refetched + useEffect(() => { + if (context.selectedTenant.get) { + refetch(); + } + }, [context.selectedTenant.get]); + + /** Deletes multiple screens. */ + useEffect(() => { + if (isDeleting && selected.length > 0) { + const screenToDelete = selected[0]; + setSelected(selected.slice(1)); + const screenToDeleteId = idFromUrl(screenToDelete.id); + DeleteV2Screens({ id: screenToDeleteId }); + } + }, [isDeleting, isDeleteSuccess]); + + // Display success messages + useEffect(() => { + if (isDeleteSuccess && selected.length === 0) { + displaySuccess(t("success-messages.screen-delete")); + setIsDeleting(false); + refetch(); + } + }, [isDeleteSuccess]); + + // Display error on unsuccessful deletion + useEffect(() => { + if (isDeleteError) { + setIsDeleting(false); + displayError(t("error-messages.screen-delete-error"), isDeleteError); + } + }, [isDeleteError]); + + /** Starts the deletion process. */ + const handleDelete = () => { + setIsDeleting(true); + setLoadingMessage(t("loading-messages.deleting-screen")); + }; + + // Error with retrieving list of screen + useEffect(() => { + if (screensGetError) { + displayError(t("error-messages.screens-load-error"), screensGetError); + } + }, [screensGetError]); + + // The columns for the table. + const columns = ScreenColumns({ + handleDelete, + apiCall: useGetV2ScreensByIdScreenGroupsQuery, + infoModalRedirect: "/group/edit", + infoModalTitle: t("info-modal.screen-in-groups"), + displayStatus: showScreenStatus, + }); + + return ( +
+ + + <> + {listData && ( + + )} + + +
+ ); +} + +export default ScreenList; diff --git a/assets/admin/components/screen/screen-list.scss b/assets/admin/components/screen/screen-list.scss new file mode 100644 index 000000000..5d6fdf162 --- /dev/null +++ b/assets/admin/components/screen/screen-list.scss @@ -0,0 +1,10 @@ +.charts { + display: flex; + flex-direction: column; + + .calendar-chart { + height: 80%; + width: 100%; + min-height: 15rem; + } +} diff --git a/assets/admin/components/screen/screen-manager.jsx b/assets/admin/components/screen/screen-manager.jsx new file mode 100644 index 000000000..c73baeb81 --- /dev/null +++ b/assets/admin/components/screen/screen-manager.jsx @@ -0,0 +1,336 @@ +import { React, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import set from "lodash.set"; +import PropTypes from "prop-types"; +import { useNavigate } from "react-router-dom"; +import { + usePostV2ScreensMutation, + usePutV2ScreensByIdMutation, +} from "../../redux/api/api.generated.ts"; +import ScreenForm from "./screen-form"; +import { + displaySuccess, + displayError, +} from "../util/list/toast-component/display-toast"; +import idFromUrl from "../util/helpers/id-from-url"; + +/** + * The screen manager component. + * + * @param {object} props The props. + * @param {object} props.initialState Initial screen state. + * @param {string} props.saveMethod POST or PUT. + * @param {string | null} props.id Screen id. + * @param {boolean} props.isLoading Is the screen state loading? + * @param {object} props.loadingError Loading error. + * @param {string | null} props.groupId The group id + * @returns {object} The screen form. + */ +function ScreenManager({ + saveMethod, + id = null, + isLoading = false, + loadingError = null, + groupId = null, + initialState = null, +}) { + const { t } = useTranslation("common", { keyPrefix: "screen-manager" }); + const [saveWithoutClose, setSaveWithoutClose] = useState(false); + const navigate = useNavigate(); + const orientationOptions = [ + { title: "Vertikal", "@id": "vertical" }, + { title: "Horisontal", "@id": "horizontal" }, + ]; + const resolutionOptions = [ + { title: "4K", "@id": "4K" }, + { title: "HD", "@id": "HD" }, + ]; + const headerText = + saveMethod === "PUT" ? t("edit-screen-header") : t("create-screen-header"); + const [loadingMessage, setLoadingMessage] = useState(""); + const [savingScreen, setSavingScreen] = useState(false); + + // Initialize to empty screen object. + const [formStateObject, setFormStateObject] = useState(null); + + const [PutV2Screens, { error: saveErrorPut, isSuccess: isSaveSuccessPut }] = + usePutV2ScreensByIdMutation(); + + // Handler for creating screen. + const [ + PostV2Screens, + { data: postData, error: saveErrorPost, isSuccess: isSaveSuccessPost }, + ] = usePostV2ScreensMutation(); + + /** If the screen is saved, display the success message */ + useEffect(() => { + if (isSaveSuccessPost || isSaveSuccessPut) { + displaySuccess(t("success-messages.saved-screen")); + setSavingScreen(false); + } + }, [isSaveSuccessPost, isSaveSuccessPut]); + + /** If the screen is saved with error, display the error message */ + useEffect(() => { + if (saveErrorPut || saveErrorPost) { + displayError( + t("error-messages.save-screen-error"), + saveErrorPut || saveErrorPost + ); + setSavingScreen(false); + } + }, [saveErrorPut, saveErrorPost]); + + /** If the screen is not loaded, display the error message */ + useEffect(() => { + if (loadingError) { + displayError(t("error-messages.load-screen-error", { id }), loadingError); + } + }, [loadingError]); + + /** + * Set state on change in input field + * + * @param {object} props - The props. + * @param {object} props.target - Event target. + */ + const handleInput = ({ target }) => { + let localFormStateObject = { ...formStateObject }; + localFormStateObject = JSON.parse(JSON.stringify(localFormStateObject)); + set(localFormStateObject, target.id, target.value); + setFormStateObject(localFormStateObject); + }; + + /** Set loaded data into form state. */ + useEffect(() => { + if (initialState) { + const localFormStateObject = JSON.parse(JSON.stringify(initialState)); + if (localFormStateObject.orientation) { + localFormStateObject.orientation = orientationOptions.filter( + (orientation) => + orientation["@id"] === localFormStateObject.orientation + ); + } + + if (localFormStateObject.resolution) { + localFormStateObject.resolution = resolutionOptions.filter( + (resolution) => resolution["@id"] === localFormStateObject.resolution + ); + } + + setFormStateObject(localFormStateObject); + } + }, [initialState]); + + /** + * Map group ids for submitting. + * + * @returns {Array | null} A mapped array with group ids or null + */ + function mapGroups() { + if (formStateObject.inScreenGroups) { + return formStateObject.inScreenGroups.map((group) => { + return idFromUrl(group); + }); + } + return []; + } + + /** + * Creates an array of playlist ids and weight filtered by region id or null + * + * @param {string} regionId RegionId for filtering + * @returns {Array | null} A mapped array with playlist ids and weight + * filtered by region id or null + */ + function getPlaylistsByRegionId(regionId) { + const { playlists } = formStateObject; + + return playlists + .filter(({ region }) => idFromUrl(region) === idFromUrl(regionId)) + .map((playlist, index) => { + return { id: idFromUrl(playlist["@id"]), weight: index }; + }); + } + + /** + * @param {string} itemId The item to remove. + * @param {Array} array The array to remove from. + */ + function removeFromArray(itemId, array) { + if (array.indexOf(itemId) >= 0) { + array.splice(array.indexOf(itemId), 1); + } + } + + /** + * Map playlists with regions and weight for submitting. + * + * @returns {Array | null} A mapped array with playlist, regions and weight or null + */ + function mapPlaylistsWithRegion() { + const returnArray = []; + const { playlists, regions } = formStateObject; + const regionIds = regions.map((r) => idFromUrl(r["@id"])); + + // The playlists all have a regionId, the following creates a unique list of relevant regions If there are not + // playlists, then an empty playlist is to be saved per region + let playlistRegions = []; + if (playlists?.length > 0) { + playlistRegions = [...new Set(playlists.map(({ region }) => region))]; + } + + // Then the playlists are mapped by region Looping through the regions that have a playlist connected... + playlistRegions.forEach((regionId) => { + // remove region id from list of regionids to finally end up with an array of region ids with empty playlist + // arrays connected + removeFromArray(regionId, regionIds); + + // Add regionsId and connected playlists to the returnarray + returnArray.push({ + playlists: getPlaylistsByRegionId(regionId), + regionId: idFromUrl(regionId), + }); + }); + // The remaining regions are added with empty playlist arrays. + if (regionIds.length > 0) { + regionIds.forEach((regionId) => + returnArray.push({ + playlists: [], + regionId: idFromUrl(regionId), + }) + ); + } + return returnArray; + } + + /** + * Gets orientation for submitting + * + * @returns {string} Orientation or empty string + */ + function getOrientation() { + const { orientation } = formStateObject; + return orientation ? orientation[0]["@id"] : ""; + } + + /** + * Gets resolution for submitting + * + * @returns {string} Resolution or empty string + */ + function getResolution() { + const { resolution } = formStateObject; + return resolution && resolution.length > 0 ? resolution[0]["@id"] : ""; + } + + /** Handles submit. */ + const handleSubmit = () => { + setSavingScreen(true); + setLoadingMessage(t("loading-messages.saving-screen")); + const localFormStateObject = JSON.parse(JSON.stringify(formStateObject)); + const { + title, + description, + size, + modifiedBy, + createdBy, + layout, + location, + enableColorSchemeChange, + } = localFormStateObject; + + const saveData = { + screenScreenInput: JSON.stringify({ + title, + description, + size, + modifiedBy, + createdBy, + layout, + location, + enableColorSchemeChange, + resolution: getResolution(), + groups: mapGroups(), + orientation: getOrientation(), + regions: mapPlaylistsWithRegion(), + }), + }; + + setLoadingMessage(t("loading-messages.saving-screen")); + + if (saveMethod === "POST") { + PostV2Screens(saveData); + } else if (saveMethod === "PUT") { + PutV2Screens({ ...saveData, id }); + } + }; + + const handleSubmitWithRedirect = () => { + setSaveWithoutClose(true); + handleSubmit(); + }; + + /** Handle submitting is done. */ + useEffect(() => { + if (isSaveSuccessPut || isSaveSuccessPost) { + setSavingScreen(false); + + if (saveWithoutClose) { + setSaveWithoutClose(false); + + if (isSaveSuccessPost) { + navigate(`/screen/edit/${idFromUrl(postData["@id"])}`); + } + } else { + navigate("/screen/list"); + } + } + }, [isSaveSuccessPut, isSaveSuccessPost]); + + return ( + <> + {formStateObject && ( + + )} + + ); +} + +ScreenManager.propTypes = { + initialState: PropTypes.shape({ + orientation: PropTypes.string, + resolution: PropTypes.string, + description: PropTypes.string, + "@id": PropTypes.string, + enableColorSchemeChange: PropTypes.bool, + layout: PropTypes.string, + location: PropTypes.string, + regions: PropTypes.arrayOf(PropTypes.string), + screenUser: PropTypes.string, + size: PropTypes.string, + title: PropTypes.string, + }), + saveMethod: PropTypes.string.isRequired, + id: PropTypes.string, + isLoading: PropTypes.bool, + loadingError: PropTypes.shape({ + data: PropTypes.shape({ + status: PropTypes.number, + }), + }), + groupId: PropTypes.string, +}; + +export default ScreenManager; diff --git a/assets/admin/components/screen/screen-status.jsx b/assets/admin/components/screen/screen-status.jsx new file mode 100644 index 000000000..6399e0b37 --- /dev/null +++ b/assets/admin/components/screen/screen-status.jsx @@ -0,0 +1,324 @@ +import dayjs from "dayjs"; +import PropTypes from "prop-types"; +import { React, JSX, useState, useEffect } from "react"; +import { Alert, Button } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faCheck, + faCheckCircle, + faExclamationCircle, + faExclamationTriangle, + faInfoCircle, + faPlus, +} from "@fortawesome/free-solid-svg-icons"; +import { useNavigate } from "react-router-dom"; +import { useDispatch } from "react-redux"; +import idFromUrl from "../util/helpers/id-from-url"; +import { api } from "../../redux/api/api.generated.ts"; +import { displayError } from "../util/list/toast-component/display-toast"; +import FormInput from "../util/forms/form-input"; +import ConfigLoader from "../../../shared/config-loader.js"; + +/** + * Displays screen status. + * + * @param {object} props The props. + * @param {object} props.screen The screen. + * @param {string | null} props.mode The display mode: 'default' or 'minimal' + * @param {Function} props.handleInput Handler for change in input. + * @returns {JSX.Element} The status element. + */ +function ScreenStatus({ screen, handleInput = () => {}, mode = "default" }) { + const { t } = useTranslation("common", { keyPrefix: "screen-status" }); + const navigate = useNavigate(); + const dispatch = useDispatch(); + + const [clientRelease, setClientRelease] = useState(null); + const [bindKey, setBindKey] = useState(""); + const [showScreenStatus, setShowScreenStatus] = useState(false); + + const { status } = screen; + + const handleBindScreen = () => { + if (bindKey) { + dispatch( + api.endpoints.postScreenBindKey.initiate({ + id: idFromUrl(screen["@id"]), + screenBindObject: JSON.stringify({ + bindKey, + }), + }) + ).then((response) => { + if (response.error) { + const err = response.error; + displayError( + t("error-messages.error-binding", { + status: err.status, + }), + err + ); + } else { + // Set screenUser to true, to indicate it has been set. + handleInput({ target: { id: "screenUser", value: true } }); + } + }); + } + }; + + const handleUnbindScreen = () => { + if (screen?.screenUser) { + setBindKey(""); + + dispatch( + api.endpoints.postScreenUnbind.initiate({ + id: idFromUrl(screen["@id"]), + }) + ).then((response) => { + if (response.error) { + const err = response.error; + displayError( + t("error-messages.error-unbinding", { + status: err.status, + }), + err + ); + } else { + // Set screenUser and status to null, to indicate it has been removed. + handleInput({ target: { id: "screenUser", value: null } }); + } + }); + } + }; + + useEffect(() => { + if (status) { + const now = dayjs().startOf("minute").valueOf(); + + fetch(`/release.json?ts=${now}`) + .then((res) => res.json()) + .then((data) => setClientRelease(data)); + } + }, [status]); + + useEffect(() => { + const config = ConfigLoader.getConfig(); + setShowScreenStatus(config.showScreenStatus); + }, []); + + if (mode === "minimal") { + if (!status) { + return ( + + ); + } + + if ( + status.releaseTimestamp === null || + status.releaseVersion === null || + status.latestRequestDateTime === null + ) { + return ( + + ); + } + + const latestRequest = dayjs(status.latestRequestDateTime); + const oneHourAgo = dayjs().subtract(1, "hours"); + + if (status?.clientMeta?.tokenExpired) { + return ( + + ); + } + + if (latestRequest < oneHourAgo) { + return ( + + ); + } + + if (clientRelease) { + if (status?.releaseVersion !== clientRelease?.releaseVersion) { + return ( + + ); + } + + if (status?.releaseTimestamp !== clientRelease?.releaseTimestamp) { + return ( + + ); + } + } + + return ( +
+ +
+ ); + } + + const getStatusAlert = () => { + let message = t("already-bound"); + let variant = "success"; + let icon = ; + const screenBound = !!screen.screenUser; + const notRunningLatestRelease = + status && + (clientRelease?.releaseTimestamp !== status?.releaseTimestamp || + clientRelease?.releaseVersion !== status?.releaseVersion); + + if (!screenBound) { + message = t("not-bound"); + variant = "danger"; + } else if (status?.clientMeta?.tokenExpired) { + message = t("token-expired"); + variant = "danger"; + } else if ( + status && + dayjs(status?.latestRequestDateTime) < dayjs().subtract(1, "hour") + ) { + message = t("latest-request-warning"); + variant = "danger"; + icon = ; + } else if (notRunningLatestRelease) { + message = t("release-warning"); + variant = "warning"; + icon = ; + } + + return ( + +
+ {icon} + {" "} + {message} +
+ + {!screenBound && ( + <> + { + setBindKey(target?.value); + }} + name="bindKey" + value={bindKey} + label={t("bindkey-label")} + className="mb-3" + /> + + + )} + + {screenBound && ( + <> + {showScreenStatus && ( +
    + {status?.latestRequestDateTime && ( +
  • + {t("latest-request")}:{" "} + {dayjs(status.latestRequestDateTime).format( + "D/M YYYY HH:mm" + )} +
  • + )} + {status?.releaseVersion && ( +
  • + {t("release-version")}: {status.releaseVersion} + {notRunningLatestRelease && ( + <> + {" "} + ({t("newest")}: {clientRelease?.releaseVersion}) + + )} +
  • + )} + {status?.releaseTimestamp && ( +
  • + {t("release-timestamp")}:{" "} + {dayjs(status.releaseTimestamp * 1000).format( + "D/M YYYY HH:mm" + )} + {notRunningLatestRelease && ( + <> + {" "}({t("newest")}:{" "} + {clientRelease?.releaseTimestamp && + dayjs(clientRelease?.releaseTimestamp * 1000).format( + "D/M YYYY HH:mm" + )} + ) + + )} +
  • + )} +
+ )} + + + + )} +
+ ); + }; + + return <>{getStatusAlert()}; +} + +ScreenStatus.propTypes = { + screen: PropTypes.shape({ + "@id": PropTypes.string.isRequired, + screenUser: PropTypes.string, + status: PropTypes.shape({ + releaseVersion: PropTypes.string, + releaseTimestamp: PropTypes.number, + latestRequestDateTime: PropTypes.string, + clientMeta: PropTypes.shape({ + ip: PropTypes.string, + host: PropTypes.string, + userAgent: PropTypes.string, + tokenExpired: PropTypes.bool, + }), + }), + }).isRequired, + mode: PropTypes.string, + handleInput: PropTypes.func, +}; + +export default ScreenStatus; diff --git a/assets/admin/components/screen/util/campaign-icon.jsx b/assets/admin/components/screen/util/campaign-icon.jsx new file mode 100644 index 000000000..94ccafcf0 --- /dev/null +++ b/assets/admin/components/screen/util/campaign-icon.jsx @@ -0,0 +1,111 @@ +import { React, useEffect, useState } from "react"; +import { useDispatch } from "react-redux"; +import PropTypes from "prop-types"; +import { useTranslation } from "react-i18next"; +import Spinner from "react-bootstrap/Spinner"; +import idFromUrl from "../../util/helpers/id-from-url"; +import calculateIsPublished from "../../util/helpers/calculate-is-published"; +import { + api, + useGetV2ScreensByIdCampaignsQuery, + useGetV2ScreensByIdScreenGroupsQuery, +} from "../../../redux/api/api.generated.ts"; + +/** + * An icon to show if the screen has an active campaign. + * + * @param {object} props - The props. + * @param {string} props.id The id of the screen. + * @param {number} props.delay Delay the fetch. + * @returns {object} The campaign icon. + */ +function CampaignIcon({ id, delay = 1000 }) { + const { t } = useTranslation("common", { keyPrefix: "campaign-icon" }); + const dispatch = useDispatch(); + const [isOverriddenByCampaign, setIsOverriddenByCampaign] = useState(null); + const [screenCampaignsChecked, setScreenCampaignsChecked] = useState(false); + const [allCampaigns, setAllCampaigns] = useState([]); + const [getData, setGetData] = useState(false); + + const { data: campaigns, isLoading } = useGetV2ScreensByIdCampaignsQuery( + { id }, + { skip: !getData } + ); + const { data: groups, isLoading: isLoadingScreenGroups } = + useGetV2ScreensByIdScreenGroupsQuery({ id }, { skip: !getData }); + + useEffect(() => { + if (campaigns) { + setAllCampaigns( + campaigns["hydra:member"].map(({ campaign }) => campaign) + ); + setScreenCampaignsChecked(true); + } + }, [campaigns]); + + useEffect(() => { + if (groups && !isOverriddenByCampaign && screenCampaignsChecked) { + groups["hydra:member"].forEach((group) => { + dispatch( + api.endpoints.getV2ScreenGroupsByIdCampaigns.initiate({ + id: idFromUrl(group["@id"]), + }) + ).then((result) => { + let allCampaignsCopy = [...allCampaigns]; + if (allCampaignsCopy.length > 0 && result.data) { + allCampaignsCopy = allCampaignsCopy.concat( + result.data["hydra:member"].map(({ campaign }) => campaign) + ); + } + setAllCampaigns(allCampaignsCopy); + }); + }); + } + }, [groups, screenCampaignsChecked]); + + useEffect(() => { + if (allCampaigns.length > 0 && !isOverriddenByCampaign) { + allCampaigns.forEach(({ published }) => { + if (calculateIsPublished(published)) { + setIsOverriddenByCampaign(true); + } + }); + } + }, [allCampaigns]); + + useEffect(() => { + const timeout = setTimeout(() => { + setGetData(true); + }, delay); + + return () => { + clearTimeout(timeout); + }; + }, []); + + if (!getData || isLoading || isLoadingScreenGroups) { + return ( +
+
+ ); + } + + return isOverriddenByCampaign + ? t("overridden-by-campaign") + : t("not-overridden-by-campaign"); +} + +CampaignIcon.propTypes = { + id: PropTypes.string.isRequired, + delay: PropTypes.number, +}; + +export default CampaignIcon; diff --git a/assets/admin/components/screen/util/grid-generation-and-select.jsx b/assets/admin/components/screen/util/grid-generation-and-select.jsx new file mode 100644 index 000000000..a30674c6a --- /dev/null +++ b/assets/admin/components/screen/util/grid-generation-and-select.jsx @@ -0,0 +1,243 @@ +import { useState, useEffect } from "react"; +import PropTypes from "prop-types"; +import { Tabs, Tab, Alert } from "react-bootstrap"; +import { createGridArea, createGrid } from "../../../../shared/grid-generator/grid-generator"; +import { useTranslation } from "react-i18next"; +import { useDispatch } from "react-redux"; +import idFromUrl from "../../util/helpers/id-from-url"; +import PlaylistDragAndDrop from "../../playlist-drag-and-drop/playlist-drag-and-drop"; +import { api } from "../../../redux/api/api.generated.ts"; +import "./grid.scss"; + +/** + * The grid generator component. + * + * @param {object} props Props. + * @param {object} props.grid The grid to generate. + * @param {object} props.regions The regions in the grid. + * @param {boolean} props.vertical True if the screen is vertical + * @param {Function} props.handleInput - A callback on select in multiselect + * @param {string} props.screenId - A screen id + * @returns {object} The component. + */ +function GridGenerationAndSelect({ + grid, + vertical, + handleInput, + screenId, + regions = [], +}) { + const { t } = useTranslation("common"); + const dispatch = useDispatch(); + const [key, setKey] = useState(regions.length > 0 ? regions[0]["@id"] : ""); + const [selectedPlaylists, setSelectedPlaylists] = useState([]); + const gridClasses = `grid ${vertical ? "vertical" : "horizontal"}`; + // Rows and columns in grid defaults to 1. + const configColumns = grid?.columns || 1; + const configRows = grid?.rows || 1; + const gridTemplateAreas = { + gridTemplateAreas: createGrid(configColumns, configRows), + }; + + /** + * @param {object} props The props + * @param {Array} props.value The value + * @param {string} props.id The id + * @returns {Array} Mapped data + */ + function mapData({ value: inputPlaylists, id }) { + // Map to add region id to incoming data. + const localTarget = inputPlaylists.map((playlist) => { + return { + region: idFromUrl(id), + ...playlist, + }; + }); + // A copy, to be able to remove items. + let selectedPlaylistsCopy = [...selectedPlaylists]; + + // The following is used to determine if something has been removed from a list. + const regionPlaylists = selectedPlaylists + .filter(({ region }) => region === id) + .map(({ region }) => region); + + const selectedWithoutRegion = []; + + // Checks if an element has been removed from the list + if (inputPlaylists.length < regionPlaylists.length) { + selectedPlaylists.forEach((playlist) => { + if (!regionPlaylists.includes(playlist.region)) { + selectedWithoutRegion.push(playlist); + } + }); + // If a playlist is removed from a list, all the playlists in that region will be removed. + selectedPlaylistsCopy = selectedWithoutRegion; + } + + // Removes duplicates. + const localSelectedPlaylists = [ + ...localTarget, + ...selectedPlaylistsCopy, + ].filter( + (playlist, index, self) => + index === + self.findIndex( + (secondPlaylist) => + secondPlaylist["@id"] === playlist["@id"] && + secondPlaylist.region === playlist.region + ) + ); + + return localSelectedPlaylists; + } + + useEffect(() => { + if (regions.length > 0) { + const promises = []; + regions.forEach(({ "@id": id }) => { + promises.push( + dispatch( + api.endpoints.getV2ScreensByIdRegionsAndRegionIdPlaylists.initiate({ + id: screenId, + regionId: idFromUrl(id), + page: 1, + itemsPerPage: 50, + }) + ) + ); + }); + + Promise.allSettled(promises).then((results) => { + let playlists = []; + results.forEach( + ({ + value: { + originalArgs: { regionId }, + data: { "hydra:member": promisedPlaylists = null } = {}, + }, + }) => { + playlists = [ + ...playlists, + ...promisedPlaylists.map(({ playlist, weight }) => ({ + ...playlist, + weight, + region: regionId, + })), + ]; + } + ); + playlists = playlists.sort((a, b) => a.weight - b.weight); + setSelectedPlaylists(playlists); + }); + } + }, [regions]); + + useEffect(() => { + handleInput({ target: { value: selectedPlaylists, id: "playlists" } }); + }, [selectedPlaylists]); + + /** + * @param {object} props The props. + * @param {object} props.target Event target + */ + const handleChange = ({ target }) => { + const playlists = mapData(target); + setSelectedPlaylists(playlists); + }; + + /** @param {string} id - The id of the selected tab */ + const handleSelect = (id) => { + setKey(id); + }; + + /** + * Removes playlist from list of playlists, and closes modal. + * + * @param {object} inputPlaylist - InputPlaylist to remove + * @param {object} inputRegion - InputRegion to remove from + */ + const removeFromList = (inputPlaylist, inputRegion) => { + const indexOfItemToRemove = selectedPlaylists.findIndex( + ({ "@id": id, region }) => { + return region === inputRegion && id === inputPlaylist; + } + ); + const selectedPlaylistsCopy = [...selectedPlaylists]; + selectedPlaylistsCopy.splice(indexOfItemToRemove, 1); + setSelectedPlaylists(selectedPlaylistsCopy); + }; + + return ( + <> +
+
+
+ {regions && + regions.map((data) => ( +
+ {data.title} +
+ ))} +
+
+
+
+ {regions.length > 0 && ( + <> +

{t("screen-form.screen-region-playlists")}

+ + {regions && + regions.map((data) => ( + + region === idFromUrl(data["@id"]) + )} + /> + {data?.type === "touch-buttons" && ( + + {t("screen-form.touch-region-helptext")} + + )} + + ))} + + + )} +
+ + ); +} + +GridGenerationAndSelect.propTypes = { + grid: PropTypes.shape({ columns: PropTypes.number, rows: PropTypes.number }) + .isRequired, + screenId: PropTypes.string.isRequired, + vertical: PropTypes.bool.isRequired, + handleInput: PropTypes.func.isRequired, + regions: PropTypes.arrayOf(PropTypes.shape(PropTypes.any)), +}; + +export default GridGenerationAndSelect; diff --git a/assets/admin/components/screen/util/grid.scss b/assets/admin/components/screen/util/grid.scss new file mode 100644 index 000000000..2c1b2d0b4 --- /dev/null +++ b/assets/admin/components/screen/util/grid.scss @@ -0,0 +1,22 @@ +.grid { + display: grid; + max-width: 100%; + height: 500px; + + .grid-item { + background-color: lightgrey; + border: 1px dotted black; + margin: 1px; + padding: 1em; + + &.selected { + background-color: lightblue; + border: 1px solid black; + } + } + + &.horizontal { + max-width: 100%; + height: 200px; + } +} diff --git a/assets/admin/components/screen/util/screen-columns.jsx b/assets/admin/components/screen/util/screen-columns.jsx new file mode 100644 index 000000000..74d5e6c74 --- /dev/null +++ b/assets/admin/components/screen/util/screen-columns.jsx @@ -0,0 +1,73 @@ +import { React } from "react"; +import { useTranslation } from "react-i18next"; +import ListButton from "../../util/list/list-button"; +import CampaignIcon from "./campaign-icon"; +import SelectColumnHoc from "../../util/select-column-hoc"; +import ColumnHoc from "../../util/column-hoc"; +import idFromUrl from "../../util/helpers/id-from-url"; +import ScreenStatus from "../screen-status"; + +/** + * Columns for screens lists. + * + * @param {object} props - The props. + * @param {Function} props.apiCall - The api to call + * @param {string} props.infoModalRedirect - The url for redirecting in the info modal. + * @param {string} props.infoModalTitle - The info modal title. + * @param {string} props.dataKey The data key for mapping the data. + * @param {boolean} props.displayStatus Should status be displayed? + * @returns {object} The columns for the screens lists. + */ +function getScreenColumns({ + apiCall, + infoModalRedirect, + infoModalTitle, + dataKey, + displayStatus, +}) { + const { t } = useTranslation("common", { keyPrefix: "screen-list" }); + + const columns = [ + { + content: (screen) => ( + + ), + key: "groups", + label: t("columns.on-groups"), + }, + { + path: "location", + label: t("columns.location"), + }, + { + key: "campaign", + label: t("columns.campaign"), + // eslint-disable-next-line react/destructuring-assignment + content: (d) => , + }, + ]; + + if (displayStatus) { + columns.push({ + path: "status", + label: t("columns.status"), + content: (screen) => { + return ; + }, + }); + } + + return columns; +} + +const ScreenColumns = ColumnHoc(getScreenColumns); +const SelectScreenColumns = SelectColumnHoc(getScreenColumns); + +export { SelectScreenColumns, ScreenColumns }; diff --git a/assets/admin/components/screen/util/screen-gantt-chart.jsx b/assets/admin/components/screen/util/screen-gantt-chart.jsx new file mode 100644 index 000000000..e9b13f2e8 --- /dev/null +++ b/assets/admin/components/screen/util/screen-gantt-chart.jsx @@ -0,0 +1,136 @@ +import React, { useEffect, useState, useContext } from "react"; +import { RRule } from "rrule"; +import PropTypes from "prop-types"; +import { useTranslation } from "react-i18next"; +import GanttChart from "../../util/gantt-chart"; +import localStorageKeys from "../../util/local-storage-keys"; +import FormCheckbox from "../../util/forms/form-checkbox"; +import UserContext from "../../../context/user-context"; + +/** + * @param {object} props The props. + * @param {object} props.playlists The playlists to display + * @param {string} props.id The playlists to display + * @returns {object} The gantt chart view of the screen. + */ +function ScreenGanttChart({ playlists, id }) { + const { t } = useTranslation("common", { keyPrefix: "screen-gantt-chart" }); + const context = useContext(UserContext); + const [dataForGanttChart, setdataForGanttChart] = useState(); + const [showGantt, setShowGantt] = useState(false); + + /** Get show from local storage */ + useEffect(() => { + const localStorageShow = localStorage.getItem( + localStorageKeys.VIEW_GANT_SCREEN + ); + setShowGantt(localStorageShow === "true"); + }, []); + + useEffect(() => { + const playlistData = []; + // As playlists default to being published, if they have no values for + // from / to, I here create today (from), and a year from today (to). + const today = new Date(); + const year = today.getFullYear(); + const month = today.getMonth(); + const day = today.getDate(); + const inAYear = new Date(year + 1, month, day); + + playlists.forEach((playlist) => { + const { tenants } = playlist; + const redirectPossible = + tenants?.length === 0 || + !tenants.find( + (tenant) => tenant.tenantKey === context.selectedTenant.get.tenantKey + ); + + // If the playlist has scheduling, a playlist per scheduling will + // be added to the gantt chart data. + if (playlist.schedules?.length > 0) { + playlist.schedules.forEach(({ rrule, duration }) => { + // Get rrule dates in an array + // From today, to in a year - which is also the upper boundary of the gantt chart + const occurrences = RRule.fromString( + rrule.replace("\\n", "\n") + ).between(new Date(), inAYear); + + // Map published and add to data structure + occurrences.forEach((occurrence) => { + const startDateTime = new Date(occurrence); + + // End date is start date plus duration, as rrule + const endDateTime = new Date(occurrence).setSeconds( + startDateTime.getSeconds() + duration + ); + + const playlistWithPublished = { ...playlist }; + playlistWithPublished.published = {}; + playlistWithPublished.published.from = startDateTime; + playlistWithPublished.published.to = endDateTime; + playlistData.push({ ...playlistWithPublished, redirectPossible }); + }); + }); + } else { + // If there is no schedule, we just add the playlist and go by published + playlistData.push({ ...playlist, redirectPossible }); + } + }); + + // Map data so it fits amcharts + const regionData = playlistData.map((playlist) => { + return { + category: playlist["@id"], + from: playlist.published?.from || today, + to: playlist.published?.to || inAYear, + id: playlist["@id"], + title: playlist.title, + color: playlist.redirectPossible ? "lightblue" : "grey", + stroke: playlist.redirectPossible ? "black" : "grey", + redirectPossible: playlist.redirectPossible, + }; + }); + + return setdataForGanttChart(regionData); + }, [playlists]); + + /** + * Changes the show value, and saves to localstorage + * + * @param {object} props Props. + * @param {boolean} props.target The returned value from the checkbox. + */ + const changeShowScreenGantt = ({ target }) => { + const { value } = target; + localStorage.setItem(localStorageKeys.VIEW_GANT_SCREEN, value); + + setShowGantt(value); + }; + + return ( + <> + + {dataForGanttChart && showGantt && ( + + )} + + ); +} + +ScreenGanttChart.propTypes = { + playlists: PropTypes.arrayOf( + PropTypes.shape({ name: PropTypes.string, id: PropTypes.number }) + ).isRequired, + id: PropTypes.string.isRequired, +}; +export default ScreenGanttChart; diff --git a/assets/admin/components/slide/content/contacts/contact-form.jsx b/assets/admin/components/slide/content/contacts/contact-form.jsx new file mode 100644 index 000000000..7d900757e --- /dev/null +++ b/assets/admin/components/slide/content/contacts/contact-form.jsx @@ -0,0 +1,109 @@ +import { React } from "react"; +import PropTypes from "prop-types"; +import set from "lodash.set"; +import { Col, Row } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; +import FileSelector from "../file-selector"; +import FormInput from "../../../util/forms/form-input"; + +/** + * Contact form. + * + * @param {string} props The props. + * @param {Function} props.onChange Callback when contact changes. + * @param {Function} props.getInputFiles For getting contact image. + * @returns {object} Contact form component. + */ +function ContactForm({ + name, + index, + contact, + onChange, + getInputFiles, + onFilesChange, +}) { + const { t } = useTranslation("common"); + + /** + * @param {object} props The props + * @param {object} props.target Event target. + */ + const onInput = ({ target }) => { + const localContact = { ...contact }; + set(localContact, target.name, target.value); + onChange(localContact); + }; + + return ( + <> + + + + + + + + + + + + + + + + + { + onFilesChange({ + target: { id: `${name}.${index}.image`, value: target.value }, + }); + }} + acceptedMimetypes={["image/*"]} + multiple={false} + name="image" + /> + + ); +} + +ContactForm.propTypes = { + name: PropTypes.string.isRequired, + index: PropTypes.number.isRequired, + contact: PropTypes.shape({ + name: PropTypes.string, + image: PropTypes.arrayOf(PropTypes.string), + phone: PropTypes.string, + email: PropTypes.string, + title: PropTypes.string, + }).isRequired, + getInputFiles: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + onFilesChange: PropTypes.func.isRequired, +}; + +export default ContactForm; diff --git a/assets/admin/components/slide/content/contacts/contact-view.jsx b/assets/admin/components/slide/content/contacts/contact-view.jsx new file mode 100644 index 000000000..69735291c --- /dev/null +++ b/assets/admin/components/slide/content/contacts/contact-view.jsx @@ -0,0 +1,63 @@ +import { React } from "react"; +import PropTypes from "prop-types"; +import { Button, Card } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; + +/** + * A view component for contacts + * + * @param {object} props The props. + * @param {object} props.contact The contact to view + * @param {Function} props.editContact The callback on edit contact + * @param {Function} props.getInputFiles The callback for retrieving contact image + * @param {Function} props.removeContact The callback on remove contact + * @returns {object} A view component. + */ +function ContactView({ contact, editContact, getInputFiles, removeContact }) { + const { t } = useTranslation("common"); + const image = getInputFiles({ name: `contacts-image-${contact.tempId}` }); + let imageUrl = ""; + + // Get image url, different depending on if it is going to be saved or if it is saved + if (Array.isArray(image) && image.length > 0) { + imageUrl = image[0].url ? image[0].url : image[0].assets?.uri; + } + + return ( + + + + + {contact.title} {contact.name} + + + {contact.phone} {contact.email} + + + + + + ); +} + +ContactView.propTypes = { + contact: PropTypes.objectOf({ + name: PropTypes.string, + image: PropTypes.string, + phone: PropTypes.number, + title: PropTypes.string, + }).isRequired, + editContact: PropTypes.func.isRequired, + getInputFiles: PropTypes.func.isRequired, + removeContact: PropTypes.func.isRequired, +}; + +export default ContactView; diff --git a/assets/admin/components/slide/content/contacts/contacts.jsx b/assets/admin/components/slide/content/contacts/contacts.jsx new file mode 100644 index 000000000..9307c4461 --- /dev/null +++ b/assets/admin/components/slide/content/contacts/contacts.jsx @@ -0,0 +1,125 @@ +import { React, useState, useEffect } from "react"; +import PropTypes from "prop-types"; +import { Button, Card, Row } from "react-bootstrap"; +import { ulid } from "ulid"; +import { useTranslation } from "react-i18next"; +import Col from "react-bootstrap/Col"; +import ContactForm from "./contact-form"; + +/** + * A contacts component for forms. + * + * @param {string} props The props. + * @param {Array} props.inputContacts Array of contacts. + * @param {string} props.formGroupClasses Classes. + * @param {Function} props.onFilesChange When files are changed. + * @returns {object} A contacts component. + */ +function Contacts({ + name, + inputContacts, + getInputFiles, + onFilesChange, + onChange, + formGroupClasses = "", +}) { + const { t } = useTranslation("common"); + const [contacts, setContacts] = useState([]); + + // Initial state, if editing a slide with saved contacts + useEffect(() => { + if (inputContacts) { + setContacts(inputContacts); + } + }, [inputContacts]); + + const addContact = () => { + const newContacts = [...contacts]; + newContacts.push({ + id: ulid(new Date().getTime()), + name: "", + phone: "", + title: "", + email: "", + image: [], + }); + onChange({ target: { id: name, value: newContacts } }); + }; + + const updateContact = (changedContact) => { + const newContacts = [...contacts]; + const findIndex = newContacts.findIndex( + ({ id }) => id === changedContact.id + ); + newContacts[findIndex] = changedContact; + onChange({ target: { id: name, value: newContacts } }); + }; + + const removeContact = (contact) => { + const newContacts = [...contacts].filter(({ id }) => !(id === contact.id)); + onChange({ target: { id: name, value: newContacts } }); + }; + + return ( + + + + {contacts.map((contact, index) => ( + + + + + + + + + + + + ))} + + + {contacts?.length < 6 && ( + + )} + + + ); +} + +Contacts.propTypes = { + name: PropTypes.string.isRequired, + inputContacts: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string, + name: PropTypes.string, + image: PropTypes.arrayOf(PropTypes.string), + phone: PropTypes.string, + title: PropTypes.string, + email: PropTypes.string, + }) + ).isRequired, + formGroupClasses: PropTypes.string, + onChange: PropTypes.func.isRequired, + onFilesChange: PropTypes.func.isRequired, + getInputFiles: PropTypes.func.isRequired, +}; + +export default Contacts; diff --git a/assets/admin/components/slide/content/content-form.jsx b/assets/admin/components/slide/content/content-form.jsx new file mode 100644 index 000000000..bc5536436 --- /dev/null +++ b/assets/admin/components/slide/content/content-form.jsx @@ -0,0 +1,293 @@ +import { React } from "react"; +import PropTypes from "prop-types"; +import FormCheckbox from "../../util/forms/form-checkbox"; +import FormInput from "../../util/forms/form-input"; +import Select from "../../util/forms/select"; +import Contacts from "./contacts/contacts"; +import RichText from "../../util/forms/rich-text/rich-text"; +import FormTable from "../../util/forms/form-table/form-table"; +import FileSelector from "./file-selector"; +import StationSelector from "./station/station-selector"; +import RadioButtons from "../../util/forms/radio-buttons"; +import CheckboxOptions from "../../util/forms/checkbox-options"; + +/** + * Render form elements for content form. + * + * @param {object} props - The props. + * @param {Array} props.data - The data to render in the form element. + * @param {Array} props.errors - An error list, if there are validation errors. + * @param {Function} props.onChange - Callback, if the value of the field changes. + * @param {object} props.formStateObject - The form state. + * @param {Function} props.onFileChange - When file has changed call this function. + * @param {object} props.mediaData - Array of loaded media entities. + * @returns {object} Content form element. + */ +function ContentForm({ + data, + onFileChange, + formStateObject, + errors = [], + onChange = null, + mediaData = {}, +}) { + const getInputFiles = (field) => { + const inputFiles = []; + + if (Array.isArray(field)) { + field.forEach((mediaId) => { + if (Object.prototype.hasOwnProperty.call(mediaData, mediaId)) { + inputFiles.push(mediaData[mediaId]); + } + }); + } + + return inputFiles; + }; + + /** + * @param {object} formData - The data for form input. + * @returns {object | string} - Returns a rendered jsx object. + */ + function renderElement(formData) { + let returnElement; + let defaultMimetypes = null; + switch (formData.input) { + case "checkbox-options": + returnElement = ( + + ); + break; + case "image": + case "video": + case "file": + if (formData.input === "image") { + defaultMimetypes = ["image/*"]; + } else if (formData.input === "video") { + defaultMimetypes = ["video/*"]; + } + + returnElement = ( +
+ {formData?.label && ( + + )} + + + + {formData.helpText && {formData.helpText}} +
+ ); + break; + case "duration": + returnElement = ( + { + const newValue = value.target.value; + onChange({ + target: { id: "duration", value: Math.max(newValue, 1) * 1000 }, + }); + }} + /> + ); + + break; + case "input": + returnElement = ( + + ); + + break; + case "travel-plan": + returnElement = ( + + ); + + break; + case "rich-text-input": + returnElement = ( + + ); + + break; + case "radio": + returnElement = ( + + ); + + break; + case "table": + returnElement = ( + + ); + + break; + case "contacts": + returnElement = ( + + ); + + break; + case "checkbox": + returnElement = ( + + ); + + break; + case "header": + returnElement = ( +

{formData.text}

+ ); + + break; + // @TODO: This (header-h3) should be possible to create in a more efficient way, in combination with the above. + case "header-h3": + returnElement = ( +

{formData.text}

+ ); + + break; + case "textarea": + returnElement = ( + <> + {formData?.label && ( + + )} +